diff --git a/subiquity/controllers/filesystem.py b/subiquity/controllers/filesystem.py index f8350d3a..1ba71f67 100644 --- a/subiquity/controllers/filesystem.py +++ b/subiquity/controllers/filesystem.py @@ -106,6 +106,7 @@ class FilesystemController(SubiquityController): self.stop_listening_udev() await self._start_task await self._probe_task.wait() + self.convert_autoinstall_config() if not self.model.is_root_mounted(): raise Exception("autoinstall config did not mount root") if self.model.needs_bootloader_partition(): @@ -132,36 +133,36 @@ class FilesystemController(SubiquityController): async def _probe(self): with self.context.child("_probe") as context: - self._crash_reports = {} - if isinstance(self.ui.body, ProbingFailed): - self.ui.set_body(SlowProbing(self)) - schedule_task(self._wait_for_probing()) - for (restricted, kind) in [ - (False, ErrorReportKind.BLOCK_PROBE_FAIL), - (True, ErrorReportKind.DISK_PROBE_FAIL), - ]: - try: - desc = "restricted={}".format(restricted) - with context.child("probe_once", desc): - await self._probe_once_task.start(restricted) - # We wait on the task directly here, not - # self._probe_once_task.wait as if _probe_once_task - # gets cancelled, we should be cancelled too. - await asyncio.wait_for( - self._probe_once_task.task, 15.0) - except asyncio.CancelledError: - # asyncio.CancelledError is a subclass of Exception in - # Python 3.6 (sadface) - raise - except Exception: - block_discover_log.exception( - "block probing failed restricted=%s", restricted) - report = self.app.make_apport_report( - kind, "block probing", interrupt=False) - self._crash_reports[restricted] = report - continue - break - self.convert_autoinstall_config() + async with self.app.install_lock_file.shared(): + self._crash_reports = {} + if isinstance(self.ui.body, ProbingFailed): + self.ui.set_body(SlowProbing(self)) + schedule_task(self._wait_for_probing()) + for (restricted, kind) in [ + (False, ErrorReportKind.BLOCK_PROBE_FAIL), + (True, ErrorReportKind.DISK_PROBE_FAIL), + ]: + try: + desc = "restricted={}".format(restricted) + with context.child("probe_once", desc): + await self._probe_once_task.start(restricted) + # We wait on the task directly here, not + # self._probe_once_task.wait as if _probe_once_task + # gets cancelled, we should be cancelled too. + await asyncio.wait_for( + self._probe_once_task.task, 15.0) + except asyncio.CancelledError: + # asyncio.CancelledError is a subclass of Exception in + # Python 3.6 (sadface) + raise + except Exception: + block_discover_log.exception( + "block probing failed restricted=%s", restricted) + report = self.app.make_apport_report( + kind, "block probing", interrupt=False) + self._crash_reports[restricted] = report + continue + break def convert_autoinstall_config(self): log.debug("self.ai_data = %s", self.ai_data) @@ -187,8 +188,7 @@ class FilesystemController(SubiquityController): self._monitor = pyudev.Monitor.from_netlink(context) self._monitor.filter_by(subsystem='block') self._monitor.enable_receiving() - if self.app.interactive(): - self.start_listening_udev() + self.start_listening_udev() await self._probe_task.start() def start_listening_udev(self): @@ -235,6 +235,7 @@ class FilesystemController(SubiquityController): # events as merging system changes with configuration the user has # performed would be tricky. Possibly worth doing though! Just # not today. + self.convert_autoinstall_config() self.stop_listening_udev() self.ui.set_body(GuidedDiskSelectionView(self)) pr = self._crash_reports.get(False) diff --git a/subiquity/controllers/installprogress.py b/subiquity/controllers/installprogress.py index 8e2ccf0f..5078baf4 100644 --- a/subiquity/controllers/installprogress.py +++ b/subiquity/controllers/installprogress.py @@ -33,6 +33,7 @@ from curtin.util import write_file from systemd import journal import yaml + from subiquitycore.async_helpers import ( run_in_thread, schedule_task, @@ -45,6 +46,7 @@ from subiquitycore.utils import ( from subiquity.controller import SubiquityController from subiquity.controllers.error import ErrorReportKind +from subiquity.journald import journald_listener from subiquity.ui.views.installprogress import ProgressView @@ -97,7 +99,6 @@ class InstallProgressController(SubiquityController): self.model = app.base_model self.progress_view = ProgressView(self) self.install_state = InstallState.NOT_STARTED - self.journal_listener_handle = None self.reboot_clicked = asyncio.Event() if self.answers.get('reboot', False): @@ -202,21 +203,6 @@ class InstallProgressController(SubiquityController): self.progress_view.add_log_line(log_line) self.tb_extractor.feed(log_line) - def start_journald_listener(self, identifiers, callback): - reader = journal.Reader() - args = [] - for identifier in identifiers: - args.append("SYSLOG_IDENTIFIER={}".format(identifier)) - reader.add_match(*args) - - def watch(): - if reader.process() != journal.APPEND: - return - for event in reader: - callback(event) - loop = asyncio.get_event_loop() - return loop.add_reader(reader.fileno(), watch) - def _write_config(self, path, config): with open(path, 'w') as conf: datestr = '# Autogenerated by SUbiquity: {} UTC\n'.format( @@ -272,16 +258,25 @@ class InstallProgressController(SubiquityController): self.install_state = InstallState.RUNNING self.curtin_event_contexts[''] = context - self.journal_listener_handle = self.start_journald_listener( + journal_fd, watcher = journald_listener( [self._event_syslog_identifier, self._log_syslog_identifier], self._journal_event) + self.app.aio_loop.add_reader(journal_fd, watcher) curtin_cmd = self._get_curtin_command() log.debug('curtin install cmd: {}'.format(curtin_cmd)) - cp = await arun_command( - self.logged_command(curtin_cmd), check=True) + 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") + cp = await arun_command( + self.logged_command(curtin_cmd), check=True) log.debug('curtin_install completed: %s', cp.returncode) diff --git a/subiquity/controllers/refresh.py b/subiquity/controllers/refresh.py index e85b3373..2860409f 100644 --- a/subiquity/controllers/refresh.py +++ b/subiquity/controllers/refresh.py @@ -205,8 +205,7 @@ class RefreshController(SubiquityController): return CheckState.UNAVAILABLE async def start_update(self): - update_marker = os.path.join(self.app.state_dir, 'updating') - open(update_marker, 'w').close() + open(self.app.state_path('updating'), 'w').close() with self.context.child("starting_update") as context: change = await self.app.snapd.post( 'v2/snaps/{}'.format(self.snap_name), diff --git a/subiquity/core.py b/subiquity/core.py index cb329e25..eef9fd7c 100644 --- a/subiquity/core.py +++ b/subiquity/core.py @@ -39,6 +39,8 @@ from subiquitycore.core import Application from subiquity.controllers.error import ( ErrorReportKind, ) +from subiquity.journald import journald_listener +from subiquity.lockfile import Lockfile from subiquity.models.subiquity import SubiquityModel from subiquity.snapd import ( AsyncSnapd, @@ -122,7 +124,11 @@ class Subiquity(Application): if not opts.bootloader == 'none' and platform.machine() != 's390x': self.controllers.remove("Zdev") + self.journal_fd, self.journal_watcher = journald_listener( + ["subiquity"], self.subiquity_event, seek=True) super().__init__(opts) + self.install_lock_file = Lockfile(self.state_path("installing")) + self.install_running = None self.block_log_dir = block_log_dir self.kernel_cmdline = shlex.split(opts.kernel_cmdline) if opts.snaps_from_examples: @@ -152,6 +158,27 @@ class Subiquity(Application): self.install_confirmed = False + def subiquity_event(self, event): + if event["MESSAGE"] == "starting install": + if event["_PID"] == os.getpid(): + return + if not self.install_lock_file.is_exclusively_locked(): + return + from subiquity.ui.views.installprogress import ( + InstallRunning, + ) + tty = self.install_lock_file.read_content() + self.install_running = InstallRunning(self.ui.body, self, tty) + self.ui.body.show_stretchy_overlay(self.install_running) + schedule_task(self._hide_install_running()) + + async def _hide_install_running(self): + # Wait until the install has completed... + async with self.install_lock_file.shared(): + # And remove the overlay. + self.install_running = None + self.ui.body.remove_overlay() + def restart(self, remove_last_screen=True): if remove_last_screen: self._remove_last_screen() @@ -201,7 +228,7 @@ class Subiquity(Application): jsonschema.validate(self.autoinstall_config, self.base_schema) self.controllers.load("Early") if self.controllers.Early.cmds: - stamp_file = os.path.join(self.state_dir, "early-commands") + stamp_file = self.state_path("early-commands") if our_tty != primary_tty: print( _("waiting for installer running on {} to run early " @@ -229,6 +256,10 @@ class Subiquity(Application): # in next_screen below will be confusing. os.system('stty sane') + def new_event_loop(self): + super().new_event_loop() + self.aio_loop.add_reader(self.journal_fd, self.journal_watcher) + def run(self): try: if self.opts.autoinstall is not None: @@ -363,6 +394,8 @@ class Subiquity(Application): log.debug("showing new error %r", self.report_to_show.base) self.show_error_report(self.report_to_show) self.report_to_show = None + if self.install_running is not None: + self.ui.body.show_stretchy_overlay(self.install_running) elif self.autoinstall_config and not new.autoinstall_applied: if self.interactive() and self.show_progress_handle is None: self.ui.block_input = True diff --git a/subiquity/journald.py b/subiquity/journald.py new file mode 100644 index 00000000..7c4b3f2f --- /dev/null +++ b/subiquity/journald.py @@ -0,0 +1,34 @@ +# 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 systemd import journal + + +def journald_listener(identifiers, callback, seek=False): + reader = journal.Reader() + args = [] + for identifier in identifiers: + args.append("SYSLOG_IDENTIFIER={}".format(identifier)) + reader.add_match(*args) + + if seek: + reader.seek_tail() + + def watch(): + if reader.process() != journal.APPEND: + return + for event in reader: + callback(event) + return reader.fileno(), watch diff --git a/subiquity/lockfile.py b/subiquity/lockfile.py new file mode 100644 index 00000000..f6b75aeb --- /dev/null +++ b/subiquity/lockfile.py @@ -0,0 +1,80 @@ +# 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 . + +import fcntl +import logging + +from subiquitycore.async_helpers import run_in_thread + +log = logging.getLogger('subiquity.lockfile') + + +class _LockContext: + + def __init__(self, lockfile, flags): + self.lockfile = lockfile + self.flags = flags + self._kind = "???" + if flags & fcntl.LOCK_EX: + self._kind = "exclusive" + elif flags & fcntl.LOCK_SH: + self._kind = "shared" + + def __enter__(self): + log.debug("locking %s %s", self._kind, self.lockfile.path) + fcntl.flock(self.lockfile.fp, self.flags) + return self + + async def __aenter__(self): + return await run_in_thread(self.__enter__) + + def __exit__(self, etype, evalue, etb): + log.debug("unlocking %s %s", self._kind, self.lockfile.path) + fcntl.flock(self.lockfile.fp, fcntl.LOCK_UN) + + async def __aexit__(self, etype, evalue, etb): + self.__exit__(etype, evalue, etb) + + +class Lockfile: + + def __init__(self, path): + self.path = path + self.fp = open(path, 'a+') + + def read_content(self): + self.fp.seek(0) + return self.fp.read() + + def write_content(self, content): + self.fp.seek(0) + self.fp.truncate() + self.fp.write(content) + self.fp.flush() + + def exclusive(self): + return _LockContext(self, fcntl.LOCK_EX) + + def shared(self): + return _LockContext(self, fcntl.LOCK_SH) + + def is_exclusively_locked(self): + try: + fcntl.flock(self.fp, fcntl.LOCK_SH | fcntl.LOCK_NB) + except OSError: + return True + else: + fcntl.flock(self.fp, fcntl.LOCK_UN) + return False diff --git a/subiquity/ui/views/installprogress.py b/subiquity/ui/views/installprogress.py index c22c2f70..8b4d0098 100644 --- a/subiquity/ui/views/installprogress.py +++ b/subiquity/ui/views/installprogress.py @@ -33,6 +33,7 @@ from subiquitycore.ui.utils import button_pile, Padding, rewrap from subiquitycore.ui.stretchy import Stretchy from subiquitycore.ui.width import widget_width + log = logging.getLogger("subiquity.views.installprogress") @@ -227,3 +228,36 @@ class InstallConfirmation(Stretchy): self.parent.remove_overlay() if isinstance(self.parent, ProgressView): self.parent.show_continue() + + +running_text = _("""\ +The installer running on {tty} is currently installing the system. + +You can wait for this to complete or switch to a shell. +""") + + +class InstallRunning(Stretchy): + def __init__(self, parent, app, tty): + self.parent = parent + self.app = app + self.btn = Toggleable(other_btn( + _("Switch to a shell"), on_press=self._debug_shell)) + self.btn.enabled = False + self.app.aio_loop.call_later(0.5, self._enable) + widgets = [ + Text(rewrap(_(running_text).format(tty=tty))), + Text(''), + button_pile([self.btn]), + ] + super().__init__( + _(""), + widgets, + stretchy_index=0, + focus_index=2) + + def _enable(self): + self.btn.enabled = True + + def _debug_shell(self, sender): + self.app.debug_shell() diff --git a/subiquitycore/core.py b/subiquitycore/core.py index 43bc94f2..ebb9ef48 100644 --- a/subiquitycore/core.py +++ b/subiquitycore/core.py @@ -339,7 +339,7 @@ class Application: if opts.dry_run: self.root = '.subiquity' self.state_dir = os.path.join(self.root, 'run', self.project) - os.makedirs(os.path.join(self.state_dir, 'states'), exist_ok=True) + os.makedirs(self.state_path('states'), exist_ok=True) self.answers = {} if opts.answers is not None: @@ -360,7 +360,7 @@ class Application: self.scale_factor = float( os.environ.get('SUBIQUITY_REPLAY_TIMESCALE', "1")) - self.updated = os.path.exists(os.path.join(self.state_dir, 'updating')) + self.updated = os.path.exists(self.state_path('updating')) self.signal = Signal() self.prober = prober self.new_event_loop() @@ -417,13 +417,14 @@ class Application: controller.register_signals() log.debug("known signals: %s", self.signal.known_signals) + def state_path(self, *parts): + return os.path.join(self.state_dir, *parts) + def save_state(self): cur = self.controllers.cur if cur is None: return - state_path = os.path.join( - self.state_dir, 'states', cur.name) - with open(state_path, 'w') as fp: + with open(self.state_path('states', cur.name), 'w') as fp: json.dump(cur.serialize(), fp) def select_screen(self, new): @@ -435,8 +436,7 @@ class Application: except Skip: new.context.exit("(skipped)") raise - state_path = os.path.join(self.state_dir, 'last-screen') - with open(state_path, 'w') as fp: + with open(self.state_path('last-screen'), 'w') as fp: fp.write(new.name) def _move_screen(self, increment): @@ -485,9 +485,9 @@ class Application: # EventLoop ------------------------------------------------------------------- def _remove_last_screen(self): - state_path = os.path.join(self.state_dir, 'last-screen') - if os.path.exists(state_path): - os.unlink(state_path) + last_screen = self.state_path('last-screen') + if os.path.exists(last_screen): + os.unlink(last_screen) def exit(self): self._remove_last_screen() @@ -583,15 +583,14 @@ class Application: def load_serialized_state(self): for controller in self.controllers.instances: - state_path = os.path.join( - self.state_dir, 'states', controller.name) + state_path = self.state_path('states', controller.name) if not os.path.exists(state_path): continue with open(state_path) as fp: controller.deserialize(json.load(fp)) last_screen = None - state_path = os.path.join(self.state_dir, 'last-screen') + state_path = self.state_path('last-screen') if os.path.exists(state_path): with open(state_path) as fp: last_screen = fp.read().strip()