diff --git a/po/POTFILES.in b/po/POTFILES.in index 1fe47dc5..c149c731 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,6 +1,8 @@ [encoding: UTF-8] subiquity/client/client.py subiquity/client/controller.py +subiquity/client/controllers/__init__.py +subiquity/client/controllers/progress.py subiquity/client/__init__.py subiquity/client/keycodes.py subiquity/cmd/common.py @@ -30,7 +32,6 @@ subiquity/controllers/error.py subiquity/controllers/filesystem.py subiquity/controllers/identity.py subiquity/controllers/__init__.py -subiquity/controllers/installprogress.py subiquity/controllers/keyboard.py subiquity/controllers/mirror.py subiquity/controllers/network.py @@ -122,6 +123,7 @@ subiquity/models/tests/test_mirror.py subiquity/models/tests/test_subiquity.py subiquity/server/controller.py subiquity/server/controllers/__init__.py +subiquity/server/controllers/install.py subiquity/server/dryrun.py subiquity/server/errors.py subiquity/server/__init__.py diff --git a/subiquity/client/client.py b/subiquity/client/client.py index 9f658012..652f4b4d 100644 --- a/subiquity/client/client.py +++ b/subiquity/client/client.py @@ -89,7 +89,9 @@ class SubiquityClient(TuiApplication): def make_ui(self): return SubiquityUI(self, self.help_menu) - controllers = [] + controllers = [ + "Progress", + ] def __init__(self, opts): if is_linux_tty(): diff --git a/subiquity/client/controllers/__init__.py b/subiquity/client/controllers/__init__.py new file mode 100644 index 00000000..24de8e16 --- /dev/null +++ b/subiquity/client/controllers/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2020 Canonical, Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from .progress import ProgressController + +__all__ = [ + 'ProgressController', +] diff --git a/subiquity/client/controllers/progress.py b/subiquity/client/controllers/progress.py new file mode 100644 index 00000000..e02bdf12 --- /dev/null +++ b/subiquity/client/controllers/progress.py @@ -0,0 +1,121 @@ +# Copyright 2015 Canonical, Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import asyncio +import logging + +import aiohttp + +from subiquitycore.context import with_context + +from subiquity.client.controller import SubiquityTuiController +from subiquity.common.types import InstallState +from subiquity.ui.views.installprogress import ( + InstallRunning, + ProgressView, + ) + + +log = logging.getLogger("subiquity.client.controllers.progress") + + +class ProgressController(SubiquityTuiController): + + endpoint_name = 'install' + + def __init__(self, app): + super().__init__(app) + self.progress_view = ProgressView(self) + self.install_state = None + self.crash_report_ref = None + self.answers = app.answers.get("InstallProgress", {}) + + def event(self, event): + if event["SUBIQUITY_EVENT_TYPE"] == "start": + self.progress_view.event_start( + event["SUBIQUITY_CONTEXT_ID"], + event.get("SUBIQUITY_CONTEXT_PARENT_ID"), + event["MESSAGE"]) + elif event["SUBIQUITY_EVENT_TYPE"] == "finish": + self.progress_view.event_finish( + event["SUBIQUITY_CONTEXT_ID"]) + + def log_line(self, event): + log_line = event['MESSAGE'] + self.progress_view.add_log_line(log_line) + + def cancel(self): + pass + + def start(self): + self.app.aio_loop.create_task(self._wait_status()) + + def click_reboot(self): + self.app.aio_loop.create_task(self.send_reboot_and_wait()) + + async def send_reboot_and_wait(self): + try: + await self.app.client.reboot.POST() + except aiohttp.ClientError: + pass + self.app.exit() + + @with_context() + async def _wait_status(self, context): + install_running = None + while True: + try: + install_status = await self.endpoint.status.GET( + cur=self.install_state) + except aiohttp.ClientError: + await asyncio.sleep(1) + continue + self.install_state = install_status.state + + self.progress_view.update_for_state(self.install_state) + if self.ui.body is self.progress_view: + self.ui.set_header(self.progress_view.title) + + if install_status.error is not None: + if self.crash_report_ref is None: + self.crash_report_ref = install_status.error + self.ui.set_body(self.progress_view) + self.app.show_error_report(self.crash_report_ref) + + if self.install_state == InstallState.NEEDS_CONFIRMATION: + if self.showing: + self.app.show_confirm_install() + + if self.install_state == InstallState.RUNNING: + if install_status.confirming_tty != self.app.our_tty: + install_running = InstallRunning( + self.app, install_status.confirming_tty) + self.app.add_global_overlay(install_running) + else: + if install_running is not None: + self.app.remove_global_overlay(install_running) + install_running = None + + if self.install_state == InstallState.DONE: + if self.answers.get('reboot', False): + self.click_reboot() + + def make_ui(self): + if self.install_state == InstallState.NEEDS_CONFIRMATION: + self.app.show_confirm_install() + return self.progress_view + + def run_answers(self): + pass diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index ed97fdb8..299ae9e4 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -20,6 +20,8 @@ from subiquity.common.types import ( ApplicationState, ApplicationStatus, ErrorReportRef, + InstallState, + InstallStatus, ) @@ -55,3 +57,7 @@ class API: class crash: def GET() -> None: """Requests to this method will fail with a HTTP 500.""" + + class install: + class status: + def GET(cur: Optional[InstallState] = None) -> InstallStatus: ... diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 7382e252..fb82e8b9 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -183,3 +183,10 @@ class InstallState(enum.Enum): UU_CANCELLING = enum.auto() DONE = enum.auto() ERROR = enum.auto() + + +@attr.s(auto_attribs=True) +class InstallStatus: + state: InstallState + confirming_tty: str = '' + error: Optional[ErrorReportRef] = None diff --git a/subiquity/controllers/__init__.py b/subiquity/controllers/__init__.py index 36e903da..25a94520 100644 --- a/subiquity/controllers/__init__.py +++ b/subiquity/controllers/__init__.py @@ -19,7 +19,6 @@ from .debconf import DebconfController from .error import ErrorController from .filesystem import FilesystemController from .identity import IdentityController -from .installprogress import InstallProgressController from .keyboard import KeyboardController from .mirror import MirrorController from .network import NetworkController @@ -40,7 +39,6 @@ __all__ = [ 'ErrorController', 'FilesystemController', 'IdentityController', - 'InstallProgressController', 'KeyboardController', 'LateController', 'MirrorController', diff --git a/subiquity/server/controllers/__init__.py b/subiquity/server/controllers/__init__.py index 8e549e25..ee88e0f1 100644 --- a/subiquity/server/controllers/__init__.py +++ b/subiquity/server/controllers/__init__.py @@ -12,3 +12,9 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + +from .install import InstallController + +__all__ = [ + 'InstallController', +] diff --git a/subiquity/controllers/installprogress.py b/subiquity/server/controllers/install.py similarity index 75% rename from subiquity/controllers/installprogress.py rename to subiquity/server/controllers/install.py index 57244408..66e59130 100644 --- a/subiquity/controllers/installprogress.py +++ b/subiquity/server/controllers/install.py @@ -1,4 +1,4 @@ -# Copyright 2015 Canonical, Ltd. +# Copyright 2020 Canonical, Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -21,7 +21,7 @@ import re import shutil import sys import tempfile -import traceback +from typing import Optional from curtin.commands.install import ( ERROR_TARFILE, @@ -29,13 +29,10 @@ from curtin.commands.install import ( ) from curtin.util import write_file -from systemd import journal - import yaml from subiquitycore.async_helpers import ( run_in_thread, - schedule_task, ) from subiquitycore.context import Status, with_context from subiquitycore.utils import ( @@ -43,14 +40,18 @@ from subiquitycore.utils import ( astart_command, ) +from subiquity.common.apidef import API from subiquity.common.errorreport import ErrorReportKind -from subiquity.common.types import InstallState -from subiquity.controller import SubiquityTuiController +from subiquity.server.controller import ( + SubiquityController, + ) +from subiquity.common.types import ( + InstallState, + InstallStatus, + ) from subiquity.journald import journald_listen -from subiquity.ui.views.installprogress import ProgressView - -log = logging.getLogger("subiquitycore.controller.installprogress") +log = logging.getLogger("subiquity.server.controllers.install") class TracebackExtractor: @@ -72,18 +73,16 @@ class TracebackExtractor: self.traceback.append(line) -class InstallProgressController(SubiquityTuiController): +class InstallController(SubiquityController): + + endpoint = API.install def __init__(self, app): super().__init__(app) self.model = app.base_model - self.progress_view = ProgressView(self) - self.crash_report_ref = None self._install_state = InstallState.NOT_STARTED - - self.reboot_clicked = asyncio.Event() - if self.answers.get('reboot', False): - self.reboot_clicked.set() + self._install_state_event = asyncio.Event() + self.error_ref = None self.unattended_upgrades_proc = None self.unattended_upgrades_ctx = None @@ -91,71 +90,53 @@ class InstallProgressController(SubiquityTuiController): self.tb_extractor = TracebackExtractor() self.curtin_event_contexts = {} - def event(self, event): - if event["SUBIQUITY_EVENT_TYPE"] == "start": - self.progress_view.event_start( - event["SUBIQUITY_CONTEXT_ID"], - event.get("SUBIQUITY_CONTEXT_PARENT_ID"), - event["MESSAGE"]) - elif event["SUBIQUITY_EVENT_TYPE"] == "finish": - self.progress_view.event_finish( - event["SUBIQUITY_CONTEXT_ID"]) - - def log_line(self, event): - log_line = event['MESSAGE'] - self.progress_view.add_log_line(log_line) - def interactive(self): - return self.app.interactive() + return True + + async def status_GET( + self, cur: Optional[InstallState] = None) -> InstallStatus: + if cur == self.install_state: + await self._install_state_event.wait() + return InstallStatus( + self.install_state, + self.app.confirming_tty, + self.error_ref) + + def stop_uu(self): + if self.install_state == InstallState.UU_RUNNING: + self.update_state(InstallState.UU_CANCELLING) + self.app.aio_loop.create_task(self.stop_unattended_upgrades()) def start(self): - self.install_task = schedule_task(self.install()) - - @with_context() - async def apply_autoinstall_config(self, context): - await self.install_task - self.app.reboot_on_exit = True + self.install_task = self.app.aio_loop.create_task(self.install()) @property def install_state(self): return self._install_state def update_state(self, state): + self._install_state_event.set() + self._install_state_event.clear() self._install_state = state - self.progress_view.update_for_state(state) def tpath(self, *path): return os.path.join(self.model.target, *path) def curtin_error(self): + self.update_state(InstallState.ERROR) kw = {} if sys.exc_info()[0] is not None: log.exception("curtin_error") - self.progress_view.add_log_line(traceback.format_exc()) + # send traceback.format_exc() to journal? if self.tb_extractor.traceback: kw["Traceback"] = "\n".join(self.tb_extractor.traceback) - crash_report = self.app.make_apport_report( - ErrorReportKind.INSTALL_FAIL, "install failed", interrupt=False, - **kw) - if crash_report is not None: - self.crash_report_ref = crash_report.ref() - self.progress_view.finish_all() - self.progress_view.set_status( - ('info_error', _("An error has occurred"))) - if not self.showing: - self.app.controllers.index = self.controller_index - 1 - self.app.next_screen() - self.update_state(InstallState.ERROR) - if self.crash_report_ref is not None: - self.app.show_error_report(self.crash_report_ref) + self.error_ref = self.app.make_apport_report( + ErrorReportKind.INSTALL_FAIL, "install failed", **kw).ref() def logged_command(self, cmd): return ['systemd-cat', '--level-prefix=false', '--identifier=' + self.app.log_syslog_id] + cmd - def log_event(self, event): - self.curtin_log(event) - def curtin_event(self, event): e = { "EVENT_TYPE": "???", @@ -189,7 +170,7 @@ class InstallProgressController(SubiquityTuiController): if curtin_ctx is not None: curtin_ctx.exit(result=status) - def curtin_log(self, event): + def log_event(self, event): self.tb_extractor.feed(event['MESSAGE']) def _write_config(self, path, config): @@ -202,7 +183,7 @@ class InstallProgressController(SubiquityTuiController): def _get_curtin_command(self): config_file_name = 'subiquity-curtin-install.conf' - if self.opts.dry_run: + if self.app.opts.dry_run: config_location = os.path.join('.subiquity/', config_file_name) log_location = '.subiquity/install.log' event_file = "examples/curtin-events.json" @@ -219,9 +200,9 @@ class InstallProgressController(SubiquityTuiController): config_location, 'install'] log_location = INSTALL_LOG - self._write_config( - config_location, - self.model.render(syslog_identifier=self._event_syslog_id)) + ident = self._event_syslog_id + self._write_config(config_location, + self.model.render(syslog_identifier=ident)) self.app.note_file_for_apport("CurtinConfig", config_location) self.app.note_file_for_apport("CurtinLog", log_location) @@ -235,10 +216,10 @@ class InstallProgressController(SubiquityTuiController): sys.executable, '-m', 'curtin', 'unmount', '-t', target, ] - if self.opts.dry_run: + if self.app.opts.dry_run: cmd = ['sleep', str(0.2/self.app.scale_factor)] await arun_command(cmd) - if not self.opts.dry_run: + if not self.app.opts.dry_run: shutil.rmtree(target) @with_context( @@ -250,7 +231,7 @@ class InstallProgressController(SubiquityTuiController): loop = self.app.aio_loop fds = [ - journald_listen(loop, [self.app.log_syslog_id], self.curtin_log), + journald_listen(loop, [self.app.log_syslog_id], self.log_event), journald_listen(loop, [self._event_syslog_id], self.curtin_event), ] @@ -258,32 +239,24 @@ class InstallProgressController(SubiquityTuiController): log.debug('curtin install cmd: {}'.format(curtin_cmd)) - async with self.app.install_lock_file.exclusive(): - try: - our_tty = os.ttyname(0) - except OSError: - # This is a gross hack for testing in travis. - our_tty = "/dev/not a tty" - self.app.install_lock_file.write_content(our_tty) - journal.send("starting install", SYSLOG_IDENTIFIER="subiquity") - try: - cp = await arun_command( - self.logged_command(curtin_cmd), check=True) - finally: - for fd in fds: - loop.remove_reader(fd) + try: + cp = await arun_command( + self.logged_command(curtin_cmd), check=True) + finally: + for fd in fds: + loop.remove_reader(fd) log.debug('curtin_install completed: %s', cp.returncode) - def cancel(self): - pass - @with_context() async def install(self, *, context): context.set('is-install-context', True) try: - await asyncio.wait( - {e.wait() for e in self.model.install_events}) + await asyncio.wait({e.wait() for e in self.model.install_events}) + + if not self.app.interactive(): + if 'autoinstall' in self.app.kernel_cmdline: + self.model.confirm() self.update_state(InstallState.NEEDS_CONFIRMATION) @@ -315,16 +288,10 @@ class InstallProgressController(SubiquityTuiController): self.update_state(InstallState.DONE) except Exception: self.curtin_error() - if not self.interactive(): - raise - - async def move_on(self): - await self.install_task - self.app.next_screen() async def drain_curtin_events(self, *, context): waited = 0.0 - while self.progress_view.ongoing and waited < 5.0: + while len(self.curtin_event_contexts) > 1 and waited < 5.0: await asyncio.sleep(0.1) waited += 0.1 log.debug("waited %s seconds for events to drain", waited) @@ -356,7 +323,7 @@ class InstallProgressController(SubiquityTuiController): name="install_{package}", description="installing {package}") async def install_package(self, *, context, package): - if self.opts.dry_run: + if self.app.opts.dry_run: cmd = ["sleep", str(2/self.app.scale_factor)] else: cmd = [ @@ -368,7 +335,7 @@ class InstallProgressController(SubiquityTuiController): @with_context(description="restoring apt configuration") async def restore_apt_config(self, context): - if self.opts.dry_run: + if self.app.opts.dry_run: cmds = [["sleep", str(1/self.app.scale_factor)]] else: cmds = [ @@ -395,7 +362,7 @@ class InstallProgressController(SubiquityTuiController): env = os.environ.copy() env["APT_CONFIG"] = apt_conf.name[len(self.model.target):] self.unattended_upgrades_ctx = context - if self.opts.dry_run: + if self.app.opts.dry_run: self.unattended_upgrades_proc = await astart_command( self.logged_command( ["sleep", str(5/self.app.scale_factor)]), env=env) @@ -411,11 +378,10 @@ class InstallProgressController(SubiquityTuiController): os.remove(apt_conf.name) async def stop_unattended_upgrades(self): - self.progress_view.event_finish(self.unattended_upgrades_ctx) with self.unattended_upgrades_ctx.parent.child( "stop_unattended_upgrades", "cancelling update"): - if self.opts.dry_run: + if self.app.opts.dry_run: await asyncio.sleep(1) self.unattended_upgrades_proc.terminate() else: @@ -426,22 +392,6 @@ class InstallProgressController(SubiquityTuiController): '--stop-only', ]), check=True) - async def _click_reboot(self): - if self.unattended_upgrades_ctx is not None: - self.update_state(InstallState.UU_CANCELLING) - await self.stop_unattended_upgrades() - self.reboot_clicked.set() - - def click_reboot(self): - schedule_task(self._click_reboot()) - - def make_ui(self): - schedule_task(self.move_on()) - return self.progress_view - - def run_answers(self): - pass - uu_apt_conf = """\ # Config for the unattended-upgrades run to avoid failing on battery power or diff --git a/subiquity/server/server.py b/subiquity/server/server.py index 311dd3f8..404a8a06 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -111,7 +111,9 @@ class SubiquityServer(Application): project = "subiquity" from subiquity.server import controllers as controllers_mod - controllers = [] + controllers = [ + "Install", + ] def make_model(self): root = '/' diff --git a/subiquity/ui/views/tests/test_installprogress.py b/subiquity/ui/views/tests/test_installprogress.py index ac344cf3..411b9d7a 100644 --- a/subiquity/ui/views/tests/test_installprogress.py +++ b/subiquity/ui/views/tests/test_installprogress.py @@ -3,15 +3,15 @@ from unittest import mock from subiquitycore.testing import view_helpers +from subiquity.client.controllers.progress import ProgressController from subiquity.common.types import InstallState -from subiquity.controllers.installprogress import InstallProgressController from subiquity.ui.views.installprogress import ProgressView class IdentityViewTests(unittest.TestCase): def make_view(self): - controller = mock.create_autospec(spec=InstallProgressController) + controller = mock.create_autospec(spec=ProgressController) controller.app = mock.Mock() controller.app.aio_loop = None return ProgressView(controller)