Skip to content

Commit

Permalink
feat: Add the non-intercative mode to the TUI installer (#1922)
Browse files Browse the repository at this point in the history
Co-authored-by: Jeongkyu Shin <[email protected]>
  • Loading branch information
achimnol and inureyes authored Mar 13, 2024
1 parent 61a4a55 commit 4762bc8
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 144 deletions.
1 change: 1 addition & 0 deletions changes/1922.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the `--non-interactive` flag to the TUI installer
209 changes: 102 additions & 107 deletions python.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ trafaret~=2.1
treelib==1.6.1
typeguard~=2.10
typing_extensions~=4.3
textual~=0.41
textual~=0.52.1
uvloop~=0.17.0; sys_platform != "Windows" # 0.18 breaks the API and adds Python 3.12 support
yarl>=1.8.2,<2.0,!=1.9.0,!=1.9.1,!=1.9.2
zipstream-new~=1.1.8
Expand Down
97 changes: 63 additions & 34 deletions src/ai/backend/install/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import sys
import textwrap
from pathlib import Path
from typing import cast
from weakref import WeakSet

import click
Expand All @@ -17,6 +16,7 @@
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.events import Key
from textual.widgets import (
ContentSwitcher,
Footer,
Expand All @@ -43,8 +43,9 @@


class DevSetup(Static):
def __init__(self, **kwargs) -> None:
def __init__(self, *, non_interactive: bool = False, **kwargs) -> None:
super().__init__(**kwargs)
self._non_interactive = non_interactive
self._task = None

def compose(self) -> ComposeResult:
Expand All @@ -60,9 +61,9 @@ def begin_install(self, dist_info: DistInfo) -> None:
top_tasks.add(asyncio.create_task(self.install(dist_info)))

async def install(self, dist_info: DistInfo) -> None:
_log: SetupLog = cast(SetupLog, self.query_one(".log"))
_log = self.query_one(".log", SetupLog)
_log_token = current_log.set(_log)
ctx = DevContext(dist_info, self.app)
ctx = DevContext(dist_info, self.app, non_interactive=self._non_interactive)
try:
# prerequisites
await ctx.check_prerequisites()
Expand All @@ -76,7 +77,7 @@ async def install(self, dist_info: DistInfo) -> None:
install_report = InstallReport(ctx.install_info, id="install-report")
self.query_one("TabPane#tab-dev-report Label").remove()
self.query_one("TabPane#tab-dev-report").mount(install_report)
cast(TabbedContent, self.query_one("TabbedContent")).active = "tab-dev-report"
self.query_one("TabbedContent", TabbedContent).active = "tab-dev-report"
except asyncio.CancelledError:
_log.write(Text.from_markup("[red]Interrupted!"))
await asyncio.sleep(1)
Expand All @@ -91,12 +92,15 @@ async def install(self, dist_info: DistInfo) -> None:
finally:
_log.write("")
_log.write(Text.from_markup("[bright_cyan]All tasks finished. Press q/Q to exit."))
if self._non_interactive:
self.app.post_message(Key("q", "q"))
current_log.reset(_log_token)


class PackageSetup(Static):
def __init__(self, **kwargs) -> None:
def __init__(self, *, non_interactive: bool = False, **kwargs) -> None:
super().__init__(**kwargs)
self._non_interactive = non_interactive
self._task = None

def compose(self) -> ComposeResult:
Expand All @@ -112,22 +116,25 @@ def begin_install(self, dist_info: DistInfo) -> None:
top_tasks.add(asyncio.create_task(self.install(dist_info)))

async def install(self, dist_info: DistInfo) -> None:
_log: SetupLog = cast(SetupLog, self.query_one(".log"))
_log = self.query_one(".log", SetupLog)
_log_token = current_log.set(_log)
ctx = PackageContext(dist_info, self.app)
ctx = PackageContext(dist_info, self.app, non_interactive=self._non_interactive)
try:
# prerequisites
if dist_info.target_path.exists():
input_box = InputDialog(
f"The target path {dist_info.target_path} already exists. "
"Overwrite it or set a different target path.",
str(dist_info.target_path),
allow_cancel=False,
)
_log.mount(input_box)
value = await input_box.wait()
assert value is not None
dist_info.target_path = Path(value)
if ctx.non_interactive:
assert dist_info.target_path is not None
else:
if dist_info.target_path.exists():
input_box = InputDialog(
f"The target path {dist_info.target_path} already exists. "
"Overwrite it or set a different target path.",
str(dist_info.target_path),
allow_cancel=False,
)
_log.mount(input_box)
value = await input_box.wait()
assert value is not None
dist_info.target_path = Path(value)
await ctx.check_prerequisites()
# install
await ctx.install()
Expand All @@ -139,7 +146,7 @@ async def install(self, dist_info: DistInfo) -> None:
install_report = InstallReport(ctx.install_info, id="install-report")
self.query_one("TabPane#tab-pkg-report Label").remove()
self.query_one("TabPane#tab-pkg-report").mount(install_report)
cast(TabbedContent, self.query_one("TabbedContent")).active = "tab-pkg-report"
self.query_one("TabbedContent", TabbedContent).active = "tab-pkg-report"
except asyncio.CancelledError:
_log.write(Text.from_markup("[red]Interrupted!"))
await asyncio.sleep(1)
Expand All @@ -154,6 +161,8 @@ async def install(self, dist_info: DistInfo) -> None:
finally:
_log.write("")
_log.write(Text.from_markup("[bright_cyan]All tasks finished. Press q/Q to exit."))
if self._non_interactive:
self.app.post_message(Key("q", "q"))
current_log.reset(_log_token)


Expand Down Expand Up @@ -313,6 +322,9 @@ def __init__(
self._dist_info = DistInfo()
self._enabled_menus = set()
self._enabled_menus.add(InstallModes.PACKAGE)
self._non_interactive = args.non_interactive
if args.target_path is not None and args.target_path != str(Path.home() / "backendai"):
self._dist_info.target_path = Path(args.target_path)
mode = args.mode
try:
self._build_root = find_build_root()
Expand Down Expand Up @@ -366,14 +378,22 @@ def compose(self) -> ComposeResult:
)
yield Label(id="mode-desc")

async def on_mount(self) -> None:
def on_mount(self) -> None:
self.call_later(self.update_platform_info)
if self._non_interactive:
# Trigger the selected mode immediately.
lv = self.app.query_one("#mode-list", ListView)
li = self.app.query_one(f"#mode-{self._mode.lower()}", ListItem)
lv.post_message(ListView.Selected(lv, li))

async def update_platform_info(self) -> None:
os_info = await detect_os()
text = Text()
text.append("Platform: ")
text.append_text(os_info.__rich__()) # type: ignore
text.append("\n\n")
text.append("Choose the installation mode:\n(arrow keys to change, enter to select)")
cast(Static, self.query_one("#heading")).update(text)
self.query_one("#heading", Static).update(text)

def action_cursor_up(self) -> None:
self.lv.action_cursor_up()
Expand All @@ -386,20 +406,20 @@ def start_develop_mode(self) -> None:
if InstallModes.DEVELOP not in self._enabled_menus:
return
self.app.sub_title = "Development Setup"
switcher: ContentSwitcher = cast(ContentSwitcher, self.app.query_one("#top"))
switcher = self.app.query_one("#top", ContentSwitcher)
switcher.current = "dev-setup"
dev_setup: DevSetup = cast(DevSetup, self.app.query_one("#dev-setup"))
switcher.call_later(dev_setup.begin_install, self._dist_info)
dev_setup = self.app.query_one("#dev-setup", DevSetup)
self.app.call_later(dev_setup.begin_install, self._dist_info)

@on(ListView.Selected, "#mode-list", item="#mode-package")
def start_package_mode(self) -> None:
if InstallModes.PACKAGE not in self._enabled_menus:
return
self.app.sub_title = "Package Setup"
switcher: ContentSwitcher = cast(ContentSwitcher, self.app.query_one("#top"))
switcher = self.app.query_one("#top", ContentSwitcher)
switcher.current = "pkg-setup"
pkg_setup: PackageSetup = cast(PackageSetup, self.app.query_one("#pkg-setup"))
switcher.call_later(pkg_setup.begin_install, self._dist_info)
pkg_setup = self.app.query_one("#pkg-setup", PackageSetup)
self.app.call_later(pkg_setup.begin_install, self._dist_info)

@on(ListView.Selected, "#mode-list", item="#mode-maintain")
def start_maintain_mode(self) -> None:
Expand Down Expand Up @@ -430,15 +450,16 @@ def __init__(self, args: CliArgs | None = None) -> None:
mode=None,
target_path=str(Path.home() / "backendai"),
show_guide=False,
non_interactive=False,
)
self._args = args

def compose(self) -> ComposeResult:
yield Header(show_clock=True)
logo_text = textwrap.dedent(
r"""
____ _ _ _ ___
| __ ) __ _ ___| | _____ _ __ __| | / \ |_ _|
__ _ _ _ ___
| |__ __ _ ___| | _____ _ __ __| | / \ |_ _|
| _ \ / _` |/ __| |/ / _ \ '_ \ / _` | / _ \ | |
| |_) | (_| | (__| < __/ | | | (_| |_ / ___ \ | |
|____/ \__,_|\___|_|\_\___|_| |_|\__,_(_)_/ \_\___|
Expand All @@ -457,12 +478,12 @@ def compose(self) -> ComposeResult:
else:
with ContentSwitcher(id="top", initial="mode-menu"):
yield ModeMenu(self._args, id="mode-menu")
yield DevSetup(id="dev-setup")
yield PackageSetup(id="pkg-setup")
yield DevSetup(id="dev-setup", non_interactive=self._args.non_interactive)
yield PackageSetup(id="pkg-setup", non_interactive=self._args.non_interactive)
yield Footer()

async def on_mount(self) -> None:
header: Header = cast(Header, self.query_one("Header"))
def on_mount(self) -> None:
header = self.query_one("Header", Header)
header.tall = True
self.title = "Backend.AI Installer"

Expand Down Expand Up @@ -494,6 +515,12 @@ async def action_shutdown(self, message: str | None = None, exit_code: int = 0)
default=None,
help="Override the installation mode. [default: auto-detect]",
)
@click.option(
"--non-interactive",
is_flag=True,
default=False,
help="Run the installer non-interactively from the given CLI options.",
)
@click.option(
"--target-path",
type=str,
Expand All @@ -513,6 +540,7 @@ def main(
mode: InstallModes | None,
target_path: str,
show_guide: bool,
non_interactive: bool,
) -> None:
"""The installer"""
# check sudo permission
Expand All @@ -528,6 +556,7 @@ def main(
mode=mode,
target_path=target_path,
show_guide=show_guide,
non_interactive=non_interactive,
)
app = InstallerApp(args)
app.run()
21 changes: 19 additions & 2 deletions src/ai/backend/install/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,20 @@ class Context(metaclass=ABCMeta):

_post_guides: list[PostGuide]

def __init__(self, dist_info: DistInfo, app: App) -> None:
def __init__(
self,
dist_info: DistInfo,
app: App,
*,
non_interactive: bool = False,
) -> None:
self._post_guides = []
self.app = app
self.log = current_log.get()
self.cwd = Path.cwd()
self.dist_info = dist_info
self.wget_sema = asyncio.Semaphore(3)
self.non_interactive = non_interactive
self.install_info = self.hydrate_install_info()

@abstractmethod
Expand Down Expand Up @@ -264,8 +271,14 @@ async def install_halfstack(self) -> None:

async def load_fixtures(self) -> None:
await self.run_manager_cli(["mgr", "schema", "oneshot"])
with self.resource_path("ai.backend.install.fixtures", "example-users.json") as path:
await self.run_manager_cli(["mgr", "fixture", "populate", str(path)])
with self.resource_path("ai.backend.install.fixtures", "example-keypairs.json") as path:
await self.run_manager_cli(["mgr", "fixture", "populate", str(path)])
with self.resource_path(
"ai.backend.install.fixtures", "example-set-user-main-access-keys.json"
) as path:
await self.run_manager_cli(["mgr", "fixture", "populate", str(path)])
with self.resource_path(
"ai.backend.install.fixtures", "example-resource-presets.json"
) as path:
Expand Down Expand Up @@ -367,6 +380,7 @@ async def configure_manager(self) -> None:
await self.etcd_put_json("", data)
data = {}
# TODO: in dev-mode, enable these.
data["api"] = {}
data["api"]["allow-openapi-schema-introspection"] = "no"
data["api"]["allow-graphql-schema-introspection"] = "no"
if halfstack.ha_setup:
Expand Down Expand Up @@ -553,7 +567,10 @@ async def configure_client(self) -> None:
print("export BACKEND_ENDPOINT_TYPE=api", file=fp)
print(f"export BACKEND_ACCESS_KEY={keypair['access_key']}", file=fp)
print(f"export BACKEND_SECRET_KEY={keypair['secret_key']}", file=fp)
for user in keypair_data["users"]:
with self.resource_path("ai.backend.install.fixtures", "example-users.json") as user_path:
current_shell = os.environ.get("SHELL", "sh")
user_data = json.loads(Path(user_path).read_bytes())
for user in user_data["users"]:
username = user["username"]
with open(base_path / f"env-local-{username}-session.sh", "w") as fp:
print(
Expand Down
1 change: 1 addition & 0 deletions src/ai/backend/install/fixtures/example-users.json
1 change: 1 addition & 0 deletions src/ai/backend/install/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class CliArgs:
mode: InstallModes | None
target_path: str
show_guide: bool
non_interactive: bool


class PrerequisiteError(RichCast, Exception):
Expand Down

0 comments on commit 4762bc8

Please sign in to comment.