split installprogress controller into install (server) and progress (client)

This commit is contained in:
Michael Hudson-Doyle 2020-10-09 15:40:57 +13:00
parent 92252f8119
commit 3c30e14313
11 changed files with 233 additions and 119 deletions

View File

@ -1,6 +1,8 @@
[encoding: UTF-8] [encoding: UTF-8]
subiquity/client/client.py subiquity/client/client.py
subiquity/client/controller.py subiquity/client/controller.py
subiquity/client/controllers/__init__.py
subiquity/client/controllers/progress.py
subiquity/client/__init__.py subiquity/client/__init__.py
subiquity/client/keycodes.py subiquity/client/keycodes.py
subiquity/cmd/common.py subiquity/cmd/common.py
@ -30,7 +32,6 @@ subiquity/controllers/error.py
subiquity/controllers/filesystem.py subiquity/controllers/filesystem.py
subiquity/controllers/identity.py subiquity/controllers/identity.py
subiquity/controllers/__init__.py subiquity/controllers/__init__.py
subiquity/controllers/installprogress.py
subiquity/controllers/keyboard.py subiquity/controllers/keyboard.py
subiquity/controllers/mirror.py subiquity/controllers/mirror.py
subiquity/controllers/network.py subiquity/controllers/network.py
@ -122,6 +123,7 @@ subiquity/models/tests/test_mirror.py
subiquity/models/tests/test_subiquity.py subiquity/models/tests/test_subiquity.py
subiquity/server/controller.py subiquity/server/controller.py
subiquity/server/controllers/__init__.py subiquity/server/controllers/__init__.py
subiquity/server/controllers/install.py
subiquity/server/dryrun.py subiquity/server/dryrun.py
subiquity/server/errors.py subiquity/server/errors.py
subiquity/server/__init__.py subiquity/server/__init__.py

View File

@ -89,7 +89,9 @@ class SubiquityClient(TuiApplication):
def make_ui(self): def make_ui(self):
return SubiquityUI(self, self.help_menu) return SubiquityUI(self, self.help_menu)
controllers = [] controllers = [
"Progress",
]
def __init__(self, opts): def __init__(self, opts):
if is_linux_tty(): if is_linux_tty():

View File

@ -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 <http://www.gnu.org/licenses/>.
from .progress import ProgressController
__all__ = [
'ProgressController',
]

View File

@ -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 <http://www.gnu.org/licenses/>.
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

View File

@ -20,6 +20,8 @@ from subiquity.common.types import (
ApplicationState, ApplicationState,
ApplicationStatus, ApplicationStatus,
ErrorReportRef, ErrorReportRef,
InstallState,
InstallStatus,
) )
@ -55,3 +57,7 @@ class API:
class crash: class crash:
def GET() -> None: def GET() -> None:
"""Requests to this method will fail with a HTTP 500.""" """Requests to this method will fail with a HTTP 500."""
class install:
class status:
def GET(cur: Optional[InstallState] = None) -> InstallStatus: ...

View File

@ -183,3 +183,10 @@ class InstallState(enum.Enum):
UU_CANCELLING = enum.auto() UU_CANCELLING = enum.auto()
DONE = enum.auto() DONE = enum.auto()
ERROR = enum.auto() ERROR = enum.auto()
@attr.s(auto_attribs=True)
class InstallStatus:
state: InstallState
confirming_tty: str = ''
error: Optional[ErrorReportRef] = None

View File

@ -19,7 +19,6 @@ from .debconf import DebconfController
from .error import ErrorController from .error import ErrorController
from .filesystem import FilesystemController from .filesystem import FilesystemController
from .identity import IdentityController from .identity import IdentityController
from .installprogress import InstallProgressController
from .keyboard import KeyboardController from .keyboard import KeyboardController
from .mirror import MirrorController from .mirror import MirrorController
from .network import NetworkController from .network import NetworkController
@ -40,7 +39,6 @@ __all__ = [
'ErrorController', 'ErrorController',
'FilesystemController', 'FilesystemController',
'IdentityController', 'IdentityController',
'InstallProgressController',
'KeyboardController', 'KeyboardController',
'LateController', 'LateController',
'MirrorController', 'MirrorController',

View File

@ -12,3 +12,9 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from .install import InstallController
__all__ = [
'InstallController',
]

View File

@ -1,4 +1,4 @@
# Copyright 2015 Canonical, Ltd. # Copyright 2020 Canonical, Ltd.
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as # it under the terms of the GNU Affero General Public License as
@ -21,7 +21,7 @@ import re
import shutil import shutil
import sys import sys
import tempfile import tempfile
import traceback from typing import Optional
from curtin.commands.install import ( from curtin.commands.install import (
ERROR_TARFILE, ERROR_TARFILE,
@ -29,13 +29,10 @@ from curtin.commands.install import (
) )
from curtin.util import write_file from curtin.util import write_file
from systemd import journal
import yaml import yaml
from subiquitycore.async_helpers import ( from subiquitycore.async_helpers import (
run_in_thread, run_in_thread,
schedule_task,
) )
from subiquitycore.context import Status, with_context from subiquitycore.context import Status, with_context
from subiquitycore.utils import ( from subiquitycore.utils import (
@ -43,14 +40,18 @@ from subiquitycore.utils import (
astart_command, astart_command,
) )
from subiquity.common.apidef import API
from subiquity.common.errorreport import ErrorReportKind from subiquity.common.errorreport import ErrorReportKind
from subiquity.common.types import InstallState from subiquity.server.controller import (
from subiquity.controller import SubiquityTuiController SubiquityController,
)
from subiquity.common.types import (
InstallState,
InstallStatus,
)
from subiquity.journald import journald_listen from subiquity.journald import journald_listen
from subiquity.ui.views.installprogress import ProgressView
log = logging.getLogger("subiquity.server.controllers.install")
log = logging.getLogger("subiquitycore.controller.installprogress")
class TracebackExtractor: class TracebackExtractor:
@ -72,18 +73,16 @@ class TracebackExtractor:
self.traceback.append(line) self.traceback.append(line)
class InstallProgressController(SubiquityTuiController): class InstallController(SubiquityController):
endpoint = API.install
def __init__(self, app): def __init__(self, app):
super().__init__(app) super().__init__(app)
self.model = app.base_model self.model = app.base_model
self.progress_view = ProgressView(self)
self.crash_report_ref = None
self._install_state = InstallState.NOT_STARTED self._install_state = InstallState.NOT_STARTED
self._install_state_event = asyncio.Event()
self.reboot_clicked = asyncio.Event() self.error_ref = None
if self.answers.get('reboot', False):
self.reboot_clicked.set()
self.unattended_upgrades_proc = None self.unattended_upgrades_proc = None
self.unattended_upgrades_ctx = None self.unattended_upgrades_ctx = None
@ -91,71 +90,53 @@ class InstallProgressController(SubiquityTuiController):
self.tb_extractor = TracebackExtractor() self.tb_extractor = TracebackExtractor()
self.curtin_event_contexts = {} 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): 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): def start(self):
self.install_task = schedule_task(self.install()) self.install_task = self.app.aio_loop.create_task(self.install())
@with_context()
async def apply_autoinstall_config(self, context):
await self.install_task
self.app.reboot_on_exit = True
@property @property
def install_state(self): def install_state(self):
return self._install_state return self._install_state
def update_state(self, state): def update_state(self, state):
self._install_state_event.set()
self._install_state_event.clear()
self._install_state = state self._install_state = state
self.progress_view.update_for_state(state)
def tpath(self, *path): def tpath(self, *path):
return os.path.join(self.model.target, *path) return os.path.join(self.model.target, *path)
def curtin_error(self): def curtin_error(self):
self.update_state(InstallState.ERROR)
kw = {} kw = {}
if sys.exc_info()[0] is not None: if sys.exc_info()[0] is not None:
log.exception("curtin_error") log.exception("curtin_error")
self.progress_view.add_log_line(traceback.format_exc()) # send traceback.format_exc() to journal?
if self.tb_extractor.traceback: if self.tb_extractor.traceback:
kw["Traceback"] = "\n".join(self.tb_extractor.traceback) kw["Traceback"] = "\n".join(self.tb_extractor.traceback)
crash_report = self.app.make_apport_report( self.error_ref = self.app.make_apport_report(
ErrorReportKind.INSTALL_FAIL, "install failed", interrupt=False, ErrorReportKind.INSTALL_FAIL, "install failed", **kw).ref()
**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)
def logged_command(self, cmd): def logged_command(self, cmd):
return ['systemd-cat', '--level-prefix=false', return ['systemd-cat', '--level-prefix=false',
'--identifier=' + self.app.log_syslog_id] + cmd '--identifier=' + self.app.log_syslog_id] + cmd
def log_event(self, event):
self.curtin_log(event)
def curtin_event(self, event): def curtin_event(self, event):
e = { e = {
"EVENT_TYPE": "???", "EVENT_TYPE": "???",
@ -189,7 +170,7 @@ class InstallProgressController(SubiquityTuiController):
if curtin_ctx is not None: if curtin_ctx is not None:
curtin_ctx.exit(result=status) curtin_ctx.exit(result=status)
def curtin_log(self, event): def log_event(self, event):
self.tb_extractor.feed(event['MESSAGE']) self.tb_extractor.feed(event['MESSAGE'])
def _write_config(self, path, config): def _write_config(self, path, config):
@ -202,7 +183,7 @@ class InstallProgressController(SubiquityTuiController):
def _get_curtin_command(self): def _get_curtin_command(self):
config_file_name = 'subiquity-curtin-install.conf' 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) config_location = os.path.join('.subiquity/', config_file_name)
log_location = '.subiquity/install.log' log_location = '.subiquity/install.log'
event_file = "examples/curtin-events.json" event_file = "examples/curtin-events.json"
@ -219,9 +200,9 @@ class InstallProgressController(SubiquityTuiController):
config_location, 'install'] config_location, 'install']
log_location = INSTALL_LOG log_location = INSTALL_LOG
self._write_config( ident = self._event_syslog_id
config_location, self._write_config(config_location,
self.model.render(syslog_identifier=self._event_syslog_id)) self.model.render(syslog_identifier=ident))
self.app.note_file_for_apport("CurtinConfig", config_location) self.app.note_file_for_apport("CurtinConfig", config_location)
self.app.note_file_for_apport("CurtinLog", log_location) self.app.note_file_for_apport("CurtinLog", log_location)
@ -235,10 +216,10 @@ class InstallProgressController(SubiquityTuiController):
sys.executable, '-m', 'curtin', 'unmount', sys.executable, '-m', 'curtin', 'unmount',
'-t', target, '-t', target,
] ]
if self.opts.dry_run: if self.app.opts.dry_run:
cmd = ['sleep', str(0.2/self.app.scale_factor)] cmd = ['sleep', str(0.2/self.app.scale_factor)]
await arun_command(cmd) await arun_command(cmd)
if not self.opts.dry_run: if not self.app.opts.dry_run:
shutil.rmtree(target) shutil.rmtree(target)
@with_context( @with_context(
@ -250,7 +231,7 @@ class InstallProgressController(SubiquityTuiController):
loop = self.app.aio_loop loop = self.app.aio_loop
fds = [ 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), 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)) log.debug('curtin install cmd: {}'.format(curtin_cmd))
async with self.app.install_lock_file.exclusive(): try:
try: cp = await arun_command(
our_tty = os.ttyname(0) self.logged_command(curtin_cmd), check=True)
except OSError: finally:
# This is a gross hack for testing in travis. for fd in fds:
our_tty = "/dev/not a tty" loop.remove_reader(fd)
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)
log.debug('curtin_install completed: %s', cp.returncode) log.debug('curtin_install completed: %s', cp.returncode)
def cancel(self):
pass
@with_context() @with_context()
async def install(self, *, context): async def install(self, *, context):
context.set('is-install-context', True) context.set('is-install-context', True)
try: try:
await asyncio.wait( await asyncio.wait({e.wait() for e in self.model.install_events})
{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) self.update_state(InstallState.NEEDS_CONFIRMATION)
@ -315,16 +288,10 @@ class InstallProgressController(SubiquityTuiController):
self.update_state(InstallState.DONE) self.update_state(InstallState.DONE)
except Exception: except Exception:
self.curtin_error() 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): async def drain_curtin_events(self, *, context):
waited = 0.0 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) await asyncio.sleep(0.1)
waited += 0.1 waited += 0.1
log.debug("waited %s seconds for events to drain", waited) log.debug("waited %s seconds for events to drain", waited)
@ -356,7 +323,7 @@ class InstallProgressController(SubiquityTuiController):
name="install_{package}", name="install_{package}",
description="installing {package}") description="installing {package}")
async def install_package(self, *, context, 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)] cmd = ["sleep", str(2/self.app.scale_factor)]
else: else:
cmd = [ cmd = [
@ -368,7 +335,7 @@ class InstallProgressController(SubiquityTuiController):
@with_context(description="restoring apt configuration") @with_context(description="restoring apt configuration")
async def restore_apt_config(self, context): 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)]] cmds = [["sleep", str(1/self.app.scale_factor)]]
else: else:
cmds = [ cmds = [
@ -395,7 +362,7 @@ class InstallProgressController(SubiquityTuiController):
env = os.environ.copy() env = os.environ.copy()
env["APT_CONFIG"] = apt_conf.name[len(self.model.target):] env["APT_CONFIG"] = apt_conf.name[len(self.model.target):]
self.unattended_upgrades_ctx = context self.unattended_upgrades_ctx = context
if self.opts.dry_run: if self.app.opts.dry_run:
self.unattended_upgrades_proc = await astart_command( self.unattended_upgrades_proc = await astart_command(
self.logged_command( self.logged_command(
["sleep", str(5/self.app.scale_factor)]), env=env) ["sleep", str(5/self.app.scale_factor)]), env=env)
@ -411,11 +378,10 @@ class InstallProgressController(SubiquityTuiController):
os.remove(apt_conf.name) os.remove(apt_conf.name)
async def stop_unattended_upgrades(self): async def stop_unattended_upgrades(self):
self.progress_view.event_finish(self.unattended_upgrades_ctx)
with self.unattended_upgrades_ctx.parent.child( with self.unattended_upgrades_ctx.parent.child(
"stop_unattended_upgrades", "stop_unattended_upgrades",
"cancelling update"): "cancelling update"):
if self.opts.dry_run: if self.app.opts.dry_run:
await asyncio.sleep(1) await asyncio.sleep(1)
self.unattended_upgrades_proc.terminate() self.unattended_upgrades_proc.terminate()
else: else:
@ -426,22 +392,6 @@ class InstallProgressController(SubiquityTuiController):
'--stop-only', '--stop-only',
]), check=True) ]), 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 = """\ uu_apt_conf = """\
# Config for the unattended-upgrades run to avoid failing on battery power or # Config for the unattended-upgrades run to avoid failing on battery power or

View File

@ -111,7 +111,9 @@ class SubiquityServer(Application):
project = "subiquity" project = "subiquity"
from subiquity.server import controllers as controllers_mod from subiquity.server import controllers as controllers_mod
controllers = [] controllers = [
"Install",
]
def make_model(self): def make_model(self):
root = '/' root = '/'

View File

@ -3,15 +3,15 @@ from unittest import mock
from subiquitycore.testing import view_helpers from subiquitycore.testing import view_helpers
from subiquity.client.controllers.progress import ProgressController
from subiquity.common.types import InstallState from subiquity.common.types import InstallState
from subiquity.controllers.installprogress import InstallProgressController
from subiquity.ui.views.installprogress import ProgressView from subiquity.ui.views.installprogress import ProgressView
class IdentityViewTests(unittest.TestCase): class IdentityViewTests(unittest.TestCase):
def make_view(self): def make_view(self):
controller = mock.create_autospec(spec=InstallProgressController) controller = mock.create_autospec(spec=ProgressController)
controller.app = mock.Mock() controller.app = mock.Mock()
controller.app.aio_loop = None controller.app.aio_loop = None
return ProgressView(controller) return ProgressView(controller)