diff --git a/po/POTFILES.in b/po/POTFILES.in index 9b84b7ae..9b88b0ee 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,4 +1,7 @@ [encoding: UTF-8] +subiquity/client/client.py +subiquity/client/__init__.py +subiquity/client/keycodes.py subiquity/cmd/common.py subiquity/cmd/__init__.py subiquity/cmd/schema.py @@ -61,7 +64,6 @@ subiquitycore/models/network.py subiquitycore/netplan.py subiquitycore/palette.py subiquitycore/prober.py -subiquity/core.py subiquitycore/screen.py subiquitycore/signals.py subiquitycore/snapd.py @@ -100,7 +102,6 @@ subiquitycore/view.py subiquity/__init__.py subiquity/journald.py subiquity/keyboard.py -subiquity/keycodes.py subiquity/lockfile.py subiquity/__main__.py subiquity/models/filesystem.py diff --git a/subiquity/client/__init__.py b/subiquity/client/__init__.py new file mode 100644 index 00000000..8e549e25 --- /dev/null +++ b/subiquity/client/__init__.py @@ -0,0 +1,14 @@ +# 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 . diff --git a/subiquity/client/client.py b/subiquity/client/client.py new file mode 100644 index 00000000..9f658012 --- /dev/null +++ b/subiquity/client/client.py @@ -0,0 +1,455 @@ +# 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 os +import signal +import sys +import traceback + +import aiohttp + +from subiquitycore.async_helpers import ( + run_in_thread, + ) +from subiquitycore.screen import is_linux_tty +from subiquitycore.tuicontroller import Skip +from subiquitycore.tui import TuiApplication +from subiquitycore.view import BaseView + +from subiquity.client.controller import Confirm +from subiquity.client.keycodes import ( + DummyKeycodesFilter, + KeyCodesFilter, + ) +from subiquity.common.api.client import make_client_for_conn +from subiquity.common.apidef import API +from subiquity.common.errorreport import ( + ErrorReporter, + ) +from subiquity.common.serialize import from_json +from subiquity.common.types import ( + ApplicationState, + ErrorReportKind, + ErrorReportRef, + InstallState, + ) +from subiquity.journald import journald_listen +from subiquity.ui.frame import SubiquityUI +from subiquity.ui.views.error import ErrorReportStretchy +from subiquity.ui.views.help import HelpMenu +from subiquity.ui.views.installprogress import ( + InstallConfirmation, + ) + + +log = logging.getLogger('subiquity.client.client') + + +class Abort(Exception): + def __init__(self, error_report_ref): + self.error_report_ref = error_report_ref + + +DEBUG_SHELL_INTRO = _("""\ +Installer shell session activated. + +This shell session is running inside the installer environment. You +will be returned to the installer when this shell is exited, for +example by typing Control-D or 'exit'. + +Be aware that this is an ephemeral environment. Changes to this +environment will not survive a reboot. If the install has started, the +installed system will be mounted at /target.""") + + +class SubiquityClient(TuiApplication): + + snapd_socket_path = '/run/snapd.socket' + + from subiquity.client import controllers as controllers_mod + project = "subiquity" + + def make_model(self): + return None + + def make_ui(self): + return SubiquityUI(self, self.help_menu) + + controllers = [] + + def __init__(self, opts): + if is_linux_tty(): + self.input_filter = KeyCodesFilter() + else: + self.input_filter = DummyKeycodesFilter() + + self.help_menu = HelpMenu(self) + super().__init__(opts) + self.interactive = None + self.server_updated = None + self.restarting_server = False + self.global_overlays = [] + + try: + self.our_tty = os.ttyname(0) + except OSError: + self.our_tty = "not a tty" + + self.conn = aiohttp.UnixConnector(self.opts.socket) + self.client = make_client_for_conn(API, self.conn, self.resp_hook) + + self.error_reporter = ErrorReporter( + self.context.child("ErrorReporter"), self.opts.dry_run, self.root, + self.client) + + self.note_data_for_apport("SnapUpdated", str(self.updated)) + self.note_data_for_apport("UsingAnswers", str(bool(self.answers))) + + async def _restart_server(self): + log.debug("_restart_server") + try: + await self.client.meta.restart.POST() + except aiohttp.ServerDisconnectedError: + pass + self.restart(remove_last_screen=False) + + def restart(self, remove_last_screen=True, restart_server=False): + log.debug(f"restart {remove_last_screen} {restart_server}") + if remove_last_screen: + self._remove_last_screen() + if restart_server: + self.restarting_server = True + self.ui.block_input = True + self.aio_loop.create_task(self._restart_server()) + return + if self.urwid_loop is not None: + self.urwid_loop.stop() + cmdline = ['snap', 'run', 'subiquity'] + if self.opts.dry_run: + cmdline = [ + sys.executable, '-m', 'subiquity.cmd.tui', + ] + sys.argv[1:] + ['--socket', self.opts.socket] + if self.opts.server_pid is not None: + cmdline.extend(['--server-pid', self.opts.server_pid]) + log.debug("restarting %r", cmdline) + + os.execvp(cmdline[0], cmdline) + + def resp_hook(self, response): + headers = response.headers + if 'x-updated' in headers: + if self.server_updated is None: + self.server_updated = headers['x-updated'] + elif self.server_updated != headers['x-updated']: + self.restart(remove_last_screen=False) + status = headers.get('x-status') + if status == 'skip': + raise Skip + elif status == 'confirm': + raise Confirm + if headers.get('x-error-report') is not None: + ref = from_json(ErrorReportRef, headers['x-error-report']) + raise Abort(ref) + try: + response.raise_for_status() + except aiohttp.ClientError: + report = self.error_reporter.make_apport_report( + ErrorReportKind.SERVER_REQUEST_FAIL, + "request to {}".format(response.url.path)) + raise Abort(report.ref()) + return response + + async def noninteractive_confirmation(self): + await asyncio.sleep(1) + yes = _('yes') + no = _('no') + answer = no + print(_("Confirmation is required to continue.")) + print(_("Add 'autoinstall' to your kernel command line to avoid this")) + print() + prompt = "\n\n{} ({}|{})".format( + _("Continue with autoinstall?"), yes, no) + while answer != yes: + print(prompt) + answer = await run_in_thread(input) + await self.confirm_install() + + async def noninteractive_watch_install_state(self): + install_state = None + confirm_task = None + while True: + try: + install_status = await self.client.install.status.GET( + cur=install_state) + install_state = install_status.state + except aiohttp.ClientError: + await asyncio.sleep(1) + continue + if install_state == InstallState.NEEDS_CONFIRMATION: + if confirm_task is not None: + confirm_task = self.aio_loop.create_task( + self.noninteractive_confirmation()) + elif confirm_task is not None: + confirm_task.cancel() + confirm_task = None + + def subiquity_event_noninteractive(self, event): + if event['SUBIQUITY_EVENT_TYPE'] == 'start': + print('start: ' + event["MESSAGE"]) + elif event['SUBIQUITY_EVENT_TYPE'] == 'finish': + print('finish: ' + event["MESSAGE"]) + context_name = event.get('SUBIQUITY_CONTEXT_NAME', '') + if context_name == 'subiquity/Reboot/reboot': + self.exit() + + async def connect(self): + print("connecting...", end='', flush=True) + while True: + try: + status = await self.client.meta.status.GET() + except aiohttp.ClientError: + await asyncio.sleep(1) + print(".", end='', flush=True) + else: + break + print() + self.event_syslog_id = status.event_syslog_id + if status.state == ApplicationState.STARTING: + print("server is starting...", end='', flush=True) + while status.state == ApplicationState.STARTING: + await asyncio.sleep(1) + print(".", end='', flush=True) + status = await self.client.meta.status.GET() + print() + if status.state == ApplicationState.EARLY_COMMANDS: + print("running early commands...") + fd = journald_listen( + self.aio_loop, + [status.early_commands_syslog_id], + lambda e: print(e['MESSAGE'])) + status.state = await self.client.meta.status.GET(cur=status.state) + await asyncio.sleep(0.5) + self.aio_loop.remove_reader(fd) + return status + + async def start(self): + status = await self.connect() + if status.state == ApplicationState.INTERACTIVE: + self.interactive = True + await super().start() + journald_listen( + self.aio_loop, + [status.event_syslog_id], + self.controllers.Progress.event) + journald_listen( + self.aio_loop, + [status.log_syslog_id], + self.controllers.Progress.log_line) + self.error_reporter.load_reports() + for report in self.error_reporter.reports: + if report.kind == ErrorReportKind.UI and not report.seen: + self.show_error_report(report.ref()) + break + else: + self.interactive = False + if self.opts.run_on_serial: + # Thanks to the fact that we are launched with agetty's + # --skip-login option, on serial lines we can end up starting + # with some strange terminal settings (see the docs for + # --skip-login in agetty(8)). For an interactive install this + # does not matter as the settings will soon be clobbered but + # for a non-interactive one we need to clear things up or the + # prompting for confirmation will be confusing. + os.system('stty sane') + journald_listen( + self.aio_loop, + [status.event_syslog_id], + self.subiquity_event_noninteractive, + seek=True) + self.aio_loop.create_task( + self.noninteractive_watch_install_state()) + + def _exception_handler(self, loop, context): + exc = context.get('exception') + if self.restarting_server: + log.debug('ignoring %s %s during restart', exc, type(exc)) + return + if isinstance(exc, Abort): + self.show_error_report(exc.error_report_ref) + return + super()._exception_handler(loop, context) + + def extra_urwid_loop_args(self): + return dict(input_filter=self.input_filter.filter) + + def run(self): + try: + super().run() + except Exception: + print("generating crash report") + try: + report = self.make_apport_report( + ErrorReportKind.UI, "Installer UI", interrupt=False, + wait=True) + if report is not None: + print("report saved to {path}".format(path=report.path)) + except Exception: + print("report generation failed") + traceback.print_exc() + Error = getattr(self.controllers, "Error", None) + if Error is not None and Error.cmds: + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + new_loop.run_until_complete(Error.run()) + if self.interactive: + self._remove_last_screen() + raise + else: + traceback.print_exc() + signal.pause() + finally: + if self.opts.server_pid: + print('killing server {}'.format(self.opts.server_pid)) + pid = int(self.opts.server_pid) + os.kill(pid, 2) + os.waitpid(pid, 0) + + async def confirm_install(self): + await self.client.meta.confirm.POST(self.our_tty) + + def add_global_overlay(self, overlay): + self.global_overlays.append(overlay) + if isinstance(self.ui.body, BaseView): + self.ui.body.show_stretchy_overlay(overlay) + + def remove_global_overlay(self, overlay): + self.global_overlays.remove(overlay) + if isinstance(self.ui.body, BaseView): + self.ui.body.remove_overlay(overlay) + + def _remove_last_screen(self): + last_screen = self.state_path('last-screen') + if os.path.exists(last_screen): + os.unlink(last_screen) + + def exit(self): + self._remove_last_screen() + super().exit() + + def select_initial_screen(self): + last_screen = None + if self.updated: + state_path = self.state_path('last-screen') + if os.path.exists(state_path): + with open(state_path) as fp: + last_screen = fp.read().strip() + index = 0 + if last_screen: + for i, controller in enumerate(self.controllers.instances): + if controller.name == last_screen: + index = i + self.aio_loop.create_task(self._select_initial_screen(index)) + + async def _select_initial_screen(self, index): + endpoint_names = [] + for c in self.controllers.instances[:index]: + if c.endpoint_name: + endpoint_names.append(c.endpoint_name) + if endpoint_names: + await self.client.meta.mark_configured.POST(endpoint_names) + self.controllers.index = index - 1 + self.next_screen() + + async def move_screen(self, increment, coro): + try: + await super().move_screen(increment, coro) + except Confirm: + self.show_confirm_install() + + def show_confirm_install(self): + log.debug("showing InstallConfirmation over %s", self.ui.body) + self.add_global_overlay(InstallConfirmation(self)) + + async def make_view_for_controller(self, new): + view = await super().make_view_for_controller(new) + if new.answers: + self.aio_loop.call_soon(new.run_answers) + with open(self.state_path('last-screen'), 'w') as fp: + fp.write(new.name) + return view + + def show_progress(self): + self.ui.set_body(self.controllers.Progress.progress_view) + + def unhandled_input(self, key): + if key == 'f1': + if not self.ui.right_icon.current_help: + self.ui.right_icon.open_pop_up() + elif key in ['ctrl z', 'f2']: + self.debug_shell() + elif self.opts.dry_run: + self.unhandled_input_dry_run(key) + else: + super().unhandled_input(key) + + def unhandled_input_dry_run(self, key): + if key in ['ctrl e', 'ctrl r']: + interrupt = key == 'ctrl e' + try: + 1/0 + except ZeroDivisionError: + self.make_apport_report( + ErrorReportKind.UNKNOWN, "example", interrupt=interrupt) + elif key == 'ctrl u': + 1/0 + elif key == 'ctrl b': + self.aio_loop.create_task(self.client.dry_run.crash.GET()) + else: + super().unhandled_input(key) + + def debug_shell(self, after_hook=None): + + def _before(): + os.system("clear") + print(DEBUG_SHELL_INTRO) + + self.run_command_in_foreground( + ["bash"], before_hook=_before, after_hook=after_hook, cwd='/') + + def note_file_for_apport(self, key, path): + self.error_reporter.note_file_for_apport(key, path) + + def note_data_for_apport(self, key, value): + self.error_reporter.note_data_for_apport(key, value) + + def make_apport_report(self, kind, thing, *, interrupt, wait=False, **kw): + report = self.error_reporter.make_apport_report( + kind, thing, wait=wait, **kw) + + if report is not None and interrupt: + self.show_error_report(report.ref()) + + return report + + def show_error_report(self, error_ref): + log.debug("show_error_report %r", error_ref.base) + if isinstance(self.ui.body, BaseView): + w = getattr(self.ui.body._w, 'stretchy', None) + if isinstance(w, ErrorReportStretchy): + # Don't show an error if already looking at one. + return + self.add_global_overlay(ErrorReportStretchy(self, error_ref)) diff --git a/subiquity/keycodes.py b/subiquity/client/keycodes.py similarity index 98% rename from subiquity/keycodes.py rename to subiquity/client/keycodes.py index d4c0eb9a..3f568744 100644 --- a/subiquity/keycodes.py +++ b/subiquity/client/keycodes.py @@ -19,7 +19,7 @@ import os import struct import sys -log = logging.getLogger('subiquity.keycodes') +log = logging.getLogger('subiquity.client.keycodes') # /usr/include/linux/kd.h K_RAW = 0x00 diff --git a/subiquity/cmd/server.py b/subiquity/cmd/server.py index 34966c71..24b8cb5d 100644 --- a/subiquity/cmd/server.py +++ b/subiquity/cmd/server.py @@ -34,6 +34,35 @@ def make_server_args_parser(): dest='dry_run', help='menu-only, do not call installer function') parser.add_argument('--socket') + parser.add_argument('--machine-config', metavar='CONFIG', + dest='machine_config', + help="Don't Probe. Use probe data file") + parser.add_argument('--source', default=[], action='append', + dest='sources', metavar='URL', + help='install from url instead of default.') + parser.add_argument('--bootloader', + choices=['none', 'bios', 'prep', 'uefi'], + help='Override style of bootloader to use') + parser.add_argument('--autoinstall', action='store') + with open('/proc/cmdline') as fp: + cmdline = fp.read() + parser.add_argument('--kernel-cmdline', action='store', default=cmdline) + parser.add_argument( + '--snaps-from-examples', action='store_const', const=True, + dest="snaps_from_examples", + help=("Load snap details from examples/snaps instead of store. " + "Default in dry-run mode. " + "See examples/snaps/README.md for more.")) + parser.add_argument( + '--no-snaps-from-examples', action='store_const', const=False, + dest="snaps_from_examples", + help=("Load snap details from store instead of examples. " + "Default in when not in dry-run mode. " + "See examples/snaps/README.md for more.")) + parser.add_argument( + '--snap-section', action='store', default='server', + help=("Show snaps from this section of the store in the snap " + "list screen.")) return parser @@ -47,6 +76,8 @@ def main(): opts = parser.parse_args(sys.argv[1:]) logdir = LOGDIR if opts.dry_run: + if opts.snaps_from_examples is None: + opts.snaps_from_examples = True logdir = ".subiquity" if opts.socket is None: if opts.dry_run: @@ -55,6 +86,17 @@ def main(): opts.socket = '/run/subiquity/socket' os.makedirs(os.path.basename(opts.socket), exist_ok=True) + block_log_dir = os.path.join(logdir, "block") + os.makedirs(block_log_dir, exist_ok=True) + handler = logging.FileHandler(os.path.join(block_log_dir, 'discover.log')) + handler.setLevel('DEBUG') + handler.setFormatter( + logging.Formatter("%(asctime)s %(name)s:%(lineno)d %(message)s")) + logging.getLogger('probert').addHandler(handler) + handler.addFilter(lambda rec: rec.name != 'probert.network') + logging.getLogger('curtin').addHandler(handler) + logging.getLogger('block-discover').addHandler(handler) + logfiles = setup_logger(dir=logdir, base='subiquity-server') logger = logging.getLogger('subiquity') @@ -62,14 +104,14 @@ def main(): logger.info("Starting Subiquity server revision {}".format(version)) logger.info("Arguments passed: {}".format(sys.argv)) - subiquity_interface = SubiquityServer(opts) + server = SubiquityServer(opts, block_log_dir) - subiquity_interface.note_file_for_apport( + server.note_file_for_apport( "InstallerServerLog", logfiles['debug']) - subiquity_interface.note_file_for_apport( + server.note_file_for_apport( "InstallerServerLogInfo", logfiles['info']) - subiquity_interface.run() + server.run() if __name__ == '__main__': diff --git a/subiquity/cmd/tui.py b/subiquity/cmd/tui.py index ae6780f4..78a691cb 100755 --- a/subiquity/cmd/tui.py +++ b/subiquity/cmd/tui.py @@ -64,12 +64,6 @@ def make_client_args_parser(): parser.add_argument('--unicode', action='store_false', dest='ascii', help='Run the installer in unicode mode.') - parser.add_argument('--machine-config', metavar='CONFIG', - dest='machine_config', - help="Don't Probe. Use probe data file") - parser.add_argument('--bootloader', - choices=['none', 'bios', 'prep', 'uefi'], - help='Override style of bootloader to use') parser.add_argument('--screens', action='append', dest='screens', default=[]) parser.add_argument('--script', metavar="SCRIPT", action='append', @@ -79,29 +73,6 @@ def make_client_args_parser(): parser.add_argument('--click', metavar="PAT", action=ClickAction, help='Synthesize a click on a button matching PAT') parser.add_argument('--answers') - parser.add_argument('--autoinstall', action='store') - with open('/proc/cmdline') as fp: - cmdline = fp.read() - parser.add_argument('--kernel-cmdline', action='store', default=cmdline) - parser.add_argument('--source', default=[], action='append', - dest='sources', metavar='URL', - help='install from url instead of default.') - parser.add_argument( - '--snaps-from-examples', action='store_const', const=True, - dest="snaps_from_examples", - help=("Load snap details from examples/snaps instead of store. " - "Default in dry-run mode. " - "See examples/snaps/README.md for more.")) - parser.add_argument( - '--no-snaps-from-examples', action='store_const', const=False, - dest="snaps_from_examples", - help=("Load snap details from store instead of examples. " - "Default in when not in dry-run mode. " - "See examples/snaps/README.md for more.")) - parser.add_argument( - '--snap-section', action='store', default='server', - help=("Show snaps from this section of the store in the snap " - "list screen.")) parser.add_argument('--server-pid') return parser @@ -113,7 +84,7 @@ def main(): setup_environment() # setup_environment sets $APPORT_DATA_DIR which must be set before # apport is imported, which is done by this import: - from subiquity.core import Subiquity + from subiquity.client.client import SubiquityClient parser = make_client_args_parser() args = sys.argv[1:] if '--dry-run' in args: @@ -143,10 +114,8 @@ def main(): os.makedirs(os.path.basename(opts.socket), exist_ok=True) logdir = LOGDIR if opts.dry_run: - if opts.snaps_from_examples is None: - opts.snaps_from_examples = True logdir = ".subiquity" - logfiles = setup_logger(dir=logdir, base='subiquity') + logfiles = setup_logger(dir=logdir, base='subiquity-client') logger = logging.getLogger('subiquity') version = os.environ.get("SNAP_REVISION", "unknown") @@ -178,17 +147,6 @@ def main(): "cloud-init status: %r, assumed disabled", status_txt) - block_log_dir = os.path.join(logdir, "block") - os.makedirs(block_log_dir, exist_ok=True) - handler = logging.FileHandler(os.path.join(block_log_dir, 'discover.log')) - handler.setLevel('DEBUG') - handler.setFormatter( - logging.Formatter("%(asctime)s %(name)s:%(lineno)d %(message)s")) - logging.getLogger('probert').addHandler(handler) - handler.addFilter(lambda rec: rec.name != 'probert.network') - logging.getLogger('curtin').addHandler(handler) - logging.getLogger('block-discover').addHandler(handler) - if opts.ssh: from subiquity.ui.views.help import ( ssh_help_texts, get_installer_password) @@ -219,7 +177,7 @@ def main(): opts.answers.close() opts.answers = None - subiquity_interface = Subiquity(opts, block_log_dir) + subiquity_interface = SubiquityClient(opts) subiquity_interface.note_file_for_apport( "InstallerLog", logfiles['debug']) diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 9f15e7ce..ed97fdb8 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -13,9 +13,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import List, Optional + from subiquity.common.api.defs import api from subiquity.common.types import ( ApplicationState, + ApplicationStatus, ErrorReportRef, ) @@ -26,9 +29,18 @@ class API: class meta: class status: - def GET() -> ApplicationState: + def GET(cur: Optional[ApplicationState] = None) \ + -> ApplicationStatus: """Get the installer state.""" + class mark_configured: + def POST(endpoint_names: List[str]) -> None: + """Mark the controllers for endpoint_names as configured.""" + + class confirm: + def POST(tty: str) -> None: + """Confirm that the installation should proceed.""" + class restart: def POST() -> None: """Restart the server process.""" diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 16538cb0..7382e252 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -27,6 +27,17 @@ import attr class ApplicationState(enum.Enum): STARTING = enum.auto() + EARLY_COMMANDS = enum.auto() + INTERACTIVE = enum.auto() + NON_INTERACTIVE = enum.auto() + + +@attr.s(auto_attribs=True) +class ApplicationStatus: + state: ApplicationState + early_commands_syslog_id: str + log_syslog_id: str + event_syslog_id: str class ErrorReportState(enum.Enum): diff --git a/subiquity/core.py b/subiquity/core.py deleted file mode 100644 index 75e4e723..00000000 --- a/subiquity/core.py +++ /dev/null @@ -1,648 +0,0 @@ -# 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 os -import shlex -import signal -import sys -import traceback - -import aiohttp - -import jsonschema - -from systemd import journal - -import yaml - -from subiquitycore.async_helpers import ( - run_in_thread, - schedule_task, - ) -from subiquitycore.prober import Prober -from subiquitycore.screen import is_linux_tty -from subiquitycore.tuicontroller import Skip -from subiquitycore.tui import TuiApplication -from subiquitycore.snapd import ( - AsyncSnapd, - FakeSnapdConnection, - SnapdConnection, - ) -from subiquitycore.view import BaseView - -from subiquity.common.api.client import make_client_for_conn -from subiquity.common.apidef import API -from subiquity.common.errorreport import ( - ErrorReporter, - ) -from subiquity.common.serialize import from_json -from subiquity.common.types import ( - ErrorReportKind, - ErrorReportRef, - ) -from subiquity.controller import Confirm -from subiquity.journald import journald_listen -from subiquity.keycodes import ( - DummyKeycodesFilter, - KeyCodesFilter, - ) -from subiquity.lockfile import Lockfile -from subiquity.models.subiquity import SubiquityModel -from subiquity.ui.frame import SubiquityUI -from subiquity.ui.views.error import ErrorReportStretchy -from subiquity.ui.views.help import HelpMenu - - -log = logging.getLogger('subiquity.core') - - -class Abort(Exception): - def __init__(self, error_report_ref): - self.error_report_ref = error_report_ref - - -DEBUG_SHELL_INTRO = _("""\ -Installer shell session activated. - -This shell session is running inside the installer environment. You -will be returned to the installer when this shell is exited, for -example by typing Control-D or 'exit'. - -Be aware that this is an ephemeral environment. Changes to this -environment will not survive a reboot. If the install has started, the -installed system will be mounted at /target.""") - - -class Subiquity(TuiApplication): - - snapd_socket_path = '/run/snapd.socket' - - base_schema = { - 'type': 'object', - 'properties': { - 'version': { - 'type': 'integer', - 'minimum': 1, - 'maximum': 1, - }, - }, - 'required': ['version'], - 'additionalProperties': True, - } - - from subiquity import controllers as controllers_mod - project = "subiquity" - - def make_model(self): - root = '/' - if self.opts.dry_run: - root = os.path.abspath('.subiquity') - return SubiquityModel(root, self.opts.sources) - - def make_ui(self): - return SubiquityUI(self, self.help_menu) - - controllers = [ - "Early", - "Reporting", - "Error", - "Userdata", - "Package", - "Debconf", - "Welcome", - "Refresh", - "Keyboard", - "Zdev", - "Network", - "Proxy", - "Mirror", - "Refresh", - "Filesystem", - "Identity", - "SSH", - "SnapList", - "InstallProgress", - "Late", - "Reboot", - ] - - def __init__(self, opts, block_log_dir): - if is_linux_tty(): - self.input_filter = KeyCodesFilter() - else: - self.input_filter = DummyKeycodesFilter() - - self.help_menu = HelpMenu(self) - super().__init__(opts) - - self.event_syslog_id = 'subiquity_event.{}'.format(os.getpid()) - self.log_syslog_id = 'subiquity_log.{}'.format(os.getpid()) - - self.server_updated = None - self.restarting_server = False - self.prober = Prober(opts.machine_config, self.debug_flags) - journald_listen( - self.aio_loop, ["subiquity"], self.subiquity_event, seek=True) - self.event_listeners = [] - self.install_lock_file = Lockfile(self.state_path("installing")) - self.global_overlays = [] - self.block_log_dir = block_log_dir - self.kernel_cmdline = shlex.split(opts.kernel_cmdline) - if opts.snaps_from_examples: - connection = FakeSnapdConnection( - os.path.join( - os.path.dirname( - os.path.dirname(__file__)), - "examples", "snaps"), - self.scale_factor) - else: - connection = SnapdConnection(self.root, self.snapd_socket_path) - self.snapd = AsyncSnapd(connection) - self.signal.connect_signals([ - ('network-proxy-set', lambda: schedule_task(self._proxy_set())), - ('network-change', self._network_change), - ]) - - self.conn = aiohttp.UnixConnector(self.opts.socket) - self.client = make_client_for_conn(API, self.conn, self.resp_hook) - - self.autoinstall_config = {} - self.error_reporter = ErrorReporter( - self.context.child("ErrorReporter"), self.opts.dry_run, self.root, - self.client) - - self.note_data_for_apport("SnapUpdated", str(self.updated)) - self.note_data_for_apport("UsingAnswers", str(bool(self.answers))) - - 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() - install_running = InstallRunning(self.ui.body, self, tty) - self.add_global_overlay(install_running) - schedule_task(self._hide_install_running(install_running)) - - async def _hide_install_running(self, install_running): - # Wait until the install has completed... - async with self.install_lock_file.shared(): - # And remove the overlay. - self.remove_global_overlay(install_running) - - async def _restart_server(self): - log.debug("_restart_server") - try: - await self.client.meta.restart.POST() - except aiohttp.ServerDisconnectedError: - pass - self.restart(remove_last_screen=False) - - def restart(self, remove_last_screen=True, restart_server=False): - log.debug(f"restart {remove_last_screen} {restart_server}") - if remove_last_screen: - self._remove_last_screen() - if restart_server: - self.restarting_server = True - self.ui.block_input = True - self.aio_loop.create_task(self._restart_server()) - return - if self.urwid_loop is not None: - self.urwid_loop.stop() - cmdline = ['snap', 'run', 'subiquity'] - if self.opts.dry_run: - cmdline = [ - sys.executable, '-m', 'subiquity.cmd.tui', - ] + sys.argv[1:] + ['--socket', self.opts.socket] - if self.opts.server_pid is not None: - cmdline.extend(['--server-pid', self.opts.server_pid]) - log.debug("restarting %r", cmdline) - - os.execvp(cmdline[0], cmdline) - - def get_primary_tty(self): - tty = '/dev/tty1' - for work in self.kernel_cmdline: - if work.startswith('console='): - tty = '/dev/' + work[len('console='):].split(',')[0] - return tty - - async def load_autoinstall_config(self): - with open(self.opts.autoinstall) as fp: - self.autoinstall_config = yaml.safe_load(fp) - primary_tty = self.get_primary_tty() - try: - our_tty = os.ttyname(0) - except OSError: - # This is a gross hack for testing in travis. - our_tty = "/dev/not a tty" - if not self.interactive() and our_tty != primary_tty: - while True: - print( - _("the installer running on {tty} will perform the " - "autoinstall").format(tty=primary_tty)) - print() - print(_("press enter to start a shell")) - input() - os.system("cd / && bash") - self.controllers.Reporting.start() - with self.context.child("core_validation", level="INFO"): - jsonschema.validate(self.autoinstall_config, self.base_schema) - self.controllers.Reporting.setup_autoinstall() - self.controllers.Early.setup_autoinstall() - self.controllers.Error.setup_autoinstall() - if self.controllers.Early.cmds: - stamp_file = self.state_path("early-commands") - if our_tty != primary_tty: - print( - _("waiting for installer running on {tty} to run early " - "commands").format(tty=primary_tty)) - while not os.path.exists(stamp_file): - await asyncio.sleep(1) - elif not os.path.exists(stamp_file): - await self.controllers.Early.run() - open(stamp_file, 'w').close() - with open(self.opts.autoinstall) as fp: - self.autoinstall_config = yaml.safe_load(fp) - with self.context.child("core_validation", level="INFO"): - jsonschema.validate(self.autoinstall_config, self.base_schema) - for controller in self.controllers.instances: - controller.setup_autoinstall() - if not self.interactive() and self.opts.run_on_serial: - # Thanks to the fact that we are launched with agetty's - # --skip-login option, on serial lines we can end up starting with - # some strange terminal settings (see the docs for --skip-login in - # agetty(8)). For an interactive install this does not matter as - # the settings will soon be clobbered but for a non-interactive - # one we need to clear things up or the prompting for confirmation - # in next_screen below will be confusing. - os.system('stty sane') - - def resp_hook(self, response): - headers = response.headers - if 'x-updated' in headers: - if self.server_updated is None: - self.server_updated = headers['x-updated'] - elif self.server_updated != headers['x-updated']: - self.restart(remove_last_screen=False) - status = response.headers.get('x-status') - if status == 'skip': - raise Skip - elif status == 'confirm': - raise Confirm - if headers.get('x-error-report') is not None: - ref = from_json(ErrorReportRef, headers['x-error-report']) - raise Abort(ref) - try: - response.raise_for_status() - except aiohttp.ClientError: - report = self.error_reporter.make_apport_report( - ErrorReportKind.SERVER_REQUEST_FAIL, - "request to {}".format(response.url.path)) - raise Abort(report.ref()) - return response - - def subiquity_event_noninteractive(self, event): - if event['SUBIQUITY_EVENT_TYPE'] == 'start': - print('start: ' + event["MESSAGE"]) - elif event['SUBIQUITY_EVENT_TYPE'] == 'finish': - print('finish: ' + event["MESSAGE"]) - context_name = event.get('SUBIQUITY_CONTEXT_NAME', '') - if context_name == 'subiquity/Reboot/reboot': - self.exit() - - async def connect(self): - print("connecting...", end='', flush=True) - while True: - try: - await self.client.meta.status.GET() - except aiohttp.ClientError: - await asyncio.sleep(1) - print(".", end='', flush=True) - else: - print() - break - - def load_serialized_state(self): - for controller in self.controllers.instances: - controller.load_state() - - async def start(self): - self.controllers.load_all() - self.load_serialized_state() - await self.connect() - if self.opts.autoinstall is not None: - await self.load_autoinstall_config() - if not self.interactive() and not self.opts.dry_run: - open('/run/casper-no-prompt', 'w').close() - interactive = self.interactive() - if interactive: - journald_listen( - self.aio_loop, - [self.event_syslog_id], - self.controllers.InstallProgress.event) - journald_listen( - self.aio_loop, - [self.log_syslog_id], - self.controllers.InstallProgress.log_line) - else: - journald_listen( - self.aio_loop, - [self.event_syslog_id], - self.subiquity_event_noninteractive, - seek=True) - await asyncio.sleep(1) - await super().start(start_urwid=interactive) - if not interactive: - self.select_initial_screen() - - def _exception_handler(self, loop, context): - exc = context.get('exception') - if self.restarting_server: - log.debug('ignoring %s %s during restart', exc, type(exc)) - return - if isinstance(exc, Abort): - self.show_error_report(exc.error_report_ref) - return - super()._exception_handler(loop, context) - - def _remove_last_screen(self): - last_screen = self.state_path('last-screen') - if os.path.exists(last_screen): - os.unlink(last_screen) - - def exit(self): - self._remove_last_screen() - super().exit() - - def extra_urwid_loop_args(self): - return dict(input_filter=self.input_filter.filter) - - def run(self): - try: - super().run() - except Exception: - print("generating crash report") - try: - report = self.make_apport_report( - ErrorReportKind.UI, "Installer UI", interrupt=False, - wait=True) - if report is not None: - print("report saved to {path}".format(path=report.path)) - except Exception: - print("report generation failed") - traceback.print_exc() - Error = getattr(self.controllers, "Error", None) - if Error is not None and Error.cmds: - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - new_loop.run_until_complete(Error.run()) - if self.interactive(): - self._remove_last_screen() - raise - else: - traceback.print_exc() - signal.pause() - finally: - if self.opts.server_pid: - print('killing server {}'.format(self.opts.server_pid)) - pid = int(self.opts.server_pid) - os.kill(pid, 2) - os.waitpid(pid, 0) - - def add_event_listener(self, listener): - self.event_listeners.append(listener) - - def report_start_event(self, context, description): - for listener in self.event_listeners: - listener.report_start_event(context, description) - self._maybe_push_to_journal('start', context, description) - - def report_finish_event(self, context, description, status): - for listener in self.event_listeners: - listener.report_finish_event(context, description, status) - self._maybe_push_to_journal('finish', context, description) - - def _maybe_push_to_journal(self, event_type, context, description): - if not context.get('is-install-context') and self.interactive(): - controller = context.get('controller') - if controller is None or controller.interactive(): - return - if context.get('request'): - return - indent = context.full_name().count('/') - 2 - if context.get('is-install-context') and self.interactive(): - indent -= 1 - msg = context.description - else: - msg = context.full_name() - if description: - msg += ': ' + description - msg = ' ' * indent + msg - if context.parent: - parent_id = str(context.parent.id) - else: - parent_id = '' - journal.send( - msg, - PRIORITY=context.level, - SYSLOG_IDENTIFIER=self.event_syslog_id, - SUBIQUITY_CONTEXT_NAME=context.full_name(), - SUBIQUITY_EVENT_TYPE=event_type, - SUBIQUITY_CONTEXT_ID=str(context.id), - SUBIQUITY_CONTEXT_PARENT_ID=parent_id) - - async def confirm_install(self): - self.base_model.confirm() - - def interactive(self): - if not self.autoinstall_config: - return True - return bool(self.autoinstall_config.get('interactive-sections')) - - def add_global_overlay(self, overlay): - self.global_overlays.append(overlay) - if isinstance(self.ui.body, BaseView): - self.ui.body.show_stretchy_overlay(overlay) - - def remove_global_overlay(self, overlay): - self.global_overlays.remove(overlay) - if isinstance(self.ui.body, BaseView): - self.ui.body.remove_overlay(overlay) - - def initial_controller_index(self): - if not self.updated: - return 0 - state_path = self.state_path('last-screen') - if not os.path.exists(state_path): - return 0 - with open(state_path) as fp: - last_screen = fp.read().strip() - controller_index = 0 - for i, controller in enumerate(self.controllers.instances): - if controller.name == last_screen: - controller_index = i - return controller_index - - def select_initial_screen(self): - self.error_reporter.load_reports() - for report in self.error_reporter.reports: - if report.kind == ErrorReportKind.UI and not report.seen: - self.show_error_report(report.ref()) - break - index = self.initial_controller_index() - for controller in self.controllers.instances[:index]: - controller.configured() - self.controllers.index = index - 1 - self.next_screen() - - async def move_screen(self, increment, coro): - try: - await super().move_screen(increment, coro) - except Confirm: - if self.interactive(): - log.debug("showing InstallConfirmation over %s", self.ui.body) - from subiquity.ui.views.installprogress import ( - InstallConfirmation, - ) - self.add_global_overlay(InstallConfirmation(self)) - else: - yes = _('yes') - no = _('no') - answer = no - if 'autoinstall' in self.kernel_cmdline: - answer = yes - else: - print(_("Confirmation is required to continue.")) - print(_("Add 'autoinstall' to your kernel command line to" - " avoid this")) - print() - prompt = "\n\n{} ({}|{})".format( - _("Continue with autoinstall?"), yes, no) - while answer != yes: - print(prompt) - answer = input() - self.next_screen(self.confirm_install()) - - async def make_view_for_controller(self, new): - if self.base_model.needs_confirmation(new.model_name): - raise Confirm - if new.interactive(): - view = await super().make_view_for_controller(new) - if new.answers: - self.aio_loop.call_soon(new.run_answers) - with open(self.state_path('last-screen'), 'w') as fp: - fp.write(new.name) - return view - else: - if self.autoinstall_config and not new.autoinstall_applied: - await new.apply_autoinstall_config() - new.autoinstall_applied = True - new.configured() - raise Skip - - def show_progress(self): - self.ui.set_body(self.controllers.InstallProgress.progress_view) - - def _network_change(self): - self.signal.emit_signal('snapd-network-change') - - async def _proxy_set(self): - await run_in_thread( - self.snapd.connection.configure_proxy, self.base_model.proxy) - self.signal.emit_signal('snapd-network-change') - - def unhandled_input(self, key): - if key == 'f1': - if not self.ui.right_icon.current_help: - self.ui.right_icon.open_pop_up() - elif key in ['ctrl z', 'f2']: - self.debug_shell() - elif self.opts.dry_run: - self.unhandled_input_dry_run(key) - else: - super().unhandled_input(key) - - def unhandled_input_dry_run(self, key): - if key == 'ctrl g': - from systemd import journal - - async def mock_install(): - async with self.install_lock_file.exclusive(): - self.install_lock_file.write_content("nowhere") - journal.send( - "starting install", SYSLOG_IDENTIFIER="subiquity") - await asyncio.sleep(5) - schedule_task(mock_install()) - elif key in ['ctrl e', 'ctrl r']: - interrupt = key == 'ctrl e' - try: - 1/0 - except ZeroDivisionError: - self.make_apport_report( - ErrorReportKind.UNKNOWN, "example", interrupt=interrupt) - elif key == 'ctrl u': - 1/0 - elif key == 'ctrl b': - self.aio_loop.create_task(self.client.dry_run.crash.GET()) - else: - super().unhandled_input(key) - - def debug_shell(self, after_hook=None): - - def _before(): - os.system("clear") - print(DEBUG_SHELL_INTRO) - - self.run_command_in_foreground( - ["bash"], before_hook=_before, after_hook=after_hook, cwd='/') - - def note_file_for_apport(self, key, path): - self.error_reporter.note_file_for_apport(key, path) - - def note_data_for_apport(self, key, value): - self.error_reporter.note_data_for_apport(key, value) - - def make_apport_report(self, kind, thing, *, interrupt, wait=False, **kw): - report = self.error_reporter.make_apport_report( - kind, thing, wait=wait, **kw) - - if report is not None and interrupt and self.interactive(): - self.show_error_report(report.ref()) - - return report - - def show_error_report(self, error_ref): - log.debug("show_error_report %r", error_ref.base) - if isinstance(self.ui.body, BaseView): - w = getattr(self.ui.body._w, 'stretchy', None) - if isinstance(w, ErrorReportStretchy): - # Don't show an error if already looking at one. - return - self.add_global_overlay(ErrorReportStretchy(self, error_ref)) - - def make_autoinstall(self): - config = {'version': 1} - for controller in self.controllers.instances: - controller_conf = controller.make_autoinstall() - if controller_conf: - config[controller.autoinstall_key] = controller_conf - return config diff --git a/subiquity/models/subiquity.py b/subiquity/models/subiquity.py index 9eefffa9..3d6e2c30 100644 --- a/subiquity/models/subiquity.py +++ b/subiquity/models/subiquity.py @@ -136,28 +136,23 @@ class SubiquityModel: if model_name in INSTALL_MODEL_NAMES: unconfigured = { mn for mn in INSTALL_MODEL_NAMES - if not self.is_configured(mn) + if not self._events[model_name].is_set() } elif model_name in POSTINSTALL_MODEL_NAMES: unconfigured = { mn for mn in POSTINSTALL_MODEL_NAMES - if not self.is_configured(mn) + if not self._events[model_name].is_set() } log.debug("model %s is configured, to go %s", model_name, unconfigured) - def needs_confirmation(self, model_name): + def needs_configuration(self, model_name): if model_name is None: return False - if not all(e.is_set() for e in self.install_events): - return None - return not self.confirmation.is_set() + return not self._events[model_name].is_set() def confirm(self): self.confirmation.set() - def is_configured(self, model_name): - return self._events[model_name].is_set() - def get_target_groups(self): command = ['chroot', self.target, 'getent', 'group'] if self.root != '/': diff --git a/subiquity/server/server.py b/subiquity/server/server.py index 5b38c214..311dd3f8 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -13,13 +13,25 @@ # 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 os +import shlex import sys +from typing import List, Optional from aiohttp import web +import jsonschema + +from systemd import journal + +import yaml + +from subiquitycore.async_helpers import run_in_thread, schedule_task +from subiquitycore.context import with_context from subiquitycore.core import Application +from subiquitycore.prober import Prober from subiquity.common.api.server import ( bind, @@ -33,10 +45,18 @@ from subiquity.common.errorreport import ( from subiquity.common.serialize import to_json from subiquity.common.types import ( ApplicationState, + ApplicationStatus, ErrorReportRef, + InstallState, ) from subiquity.server.controller import SubiquityController +from subiquity.models.subiquity import SubiquityModel from subiquity.server.errors import ErrorController +from subiquitycore.snapd import ( + AsyncSnapd, + FakeSnapdConnection, + SnapdConnection, + ) log = logging.getLogger('subiquity.server.server') @@ -48,28 +68,146 @@ class MetaController: self.app = app self.context = app.context.child("Meta") - async def status_GET(self) -> ApplicationState: - return self.app.status + async def status_GET(self, cur: Optional[ApplicationState] = None) \ + -> ApplicationStatus: + if cur == self.app.state: + await self.app.state_event.wait() + return ApplicationStatus( + self.app.state, + early_commands_syslog_id=self.app.early_commands_syslog_id, + event_syslog_id=self.app.event_syslog_id, + log_syslog_id=self.app.log_syslog_id) + + async def confirm_POST(self, tty: str) -> None: + self.app.confirming_tty = tty + self.app.base_model.confirm() async def restart_POST(self) -> None: self.app.restart() + async def mark_configured_POST(self, endpoint_names: List[str]) -> None: + endpoints = {getattr(API, en, None) for en in endpoint_names} + for controller in self.app.controllers.instances: + if controller.endpoint in endpoints: + controller.configured() + class SubiquityServer(Application): + snapd_socket_path = '/run/snapd.socket' + + base_schema = { + 'type': 'object', + 'properties': { + 'version': { + 'type': 'integer', + 'minimum': 1, + 'maximum': 1, + }, + }, + 'required': ['version'], + 'additionalProperties': True, + } + project = "subiquity" from subiquity.server import controllers as controllers_mod controllers = [] def make_model(self): - return None + root = '/' + if self.opts.dry_run: + root = os.path.abspath('.subiquity') + return SubiquityModel(root, self.opts.sources) - def __init__(self, opts): + def __init__(self, opts, block_log_dir): super().__init__(opts) - self.status = ApplicationState.STARTING - self.server_proc = None + self.block_log_dir = block_log_dir + self._state = ApplicationState.STARTING + self.state_event = asyncio.Event() + self.confirming_tty = '' + + self.early_commands_syslog_id = 'subiquity_commands.{}'.format( + os.getpid()) + self.event_syslog_id = 'subiquity_event.{}'.format(os.getpid()) + self.log_syslog_id = 'subiquity_log.{}'.format(os.getpid()) + self.error_reporter = ErrorReporter( self.context.child("ErrorReporter"), self.opts.dry_run, self.root) + self.prober = Prober(opts.machine_config, self.debug_flags) + self.kernel_cmdline = shlex.split(opts.kernel_cmdline) + if opts.snaps_from_examples: + connection = FakeSnapdConnection( + os.path.join( + os.path.dirname( + os.path.dirname( + os.path.dirname(__file__))), + "examples", "snaps"), + self.scale_factor) + else: + connection = SnapdConnection(self.root, self.snapd_socket_path) + self.snapd = AsyncSnapd(connection) + self.note_data_for_apport("SnapUpdated", str(self.updated)) + self.event_listeners = [] + self.autoinstall_config = None + self.signal.connect_signals([ + ('network-proxy-set', lambda: schedule_task(self._proxy_set())), + ('network-change', self._network_change), + ]) + + def load_serialized_state(self): + for controller in self.controllers.instances: + controller.load_state() + + def add_event_listener(self, listener): + self.event_listeners.append(listener) + + def _maybe_push_to_journal(self, event_type, context, description): + if not context.get('is-install-context') and self.interactive(): + controller = context.get('controller') + if controller is None or controller.interactive(): + return + if context.get('request'): + return + indent = context.full_name().count('/') - 2 + if context.get('is-install-context') and self.interactive(): + indent -= 1 + msg = context.description + else: + msg = context.full_name() + if description: + msg += ': ' + description + msg = ' ' * indent + msg + if context.parent: + parent_id = str(context.parent.id) + else: + parent_id = '' + journal.send( + msg, + PRIORITY=context.level, + SYSLOG_IDENTIFIER=self.event_syslog_id, + SUBIQUITY_CONTEXT_NAME=context.full_name(), + SUBIQUITY_EVENT_TYPE=event_type, + SUBIQUITY_CONTEXT_ID=str(context.id), + SUBIQUITY_CONTEXT_PARENT_ID=parent_id) + + def report_start_event(self, context, description): + for listener in self.event_listeners: + listener.report_start_event(context, description) + self._maybe_push_to_journal('start', context, description) + + def report_finish_event(self, context, description, status): + for listener in self.event_listeners: + listener.report_finish_event(context, description, status) + self._maybe_push_to_journal('finish', context, description) + + @property + def state(self): + return self._state + + def update_state(self, state): + self._state = state + self.state_event.set() + self.state_event.clear() def note_file_for_apport(self, key, path): self.error_reporter.note_file_for_apport(key, path) @@ -81,22 +219,30 @@ class SubiquityServer(Application): return self.error_reporter.make_apport_report( kind, thing, wait=wait, **kw) + def interactive(self): + if not self.autoinstall_config: + return True + return bool(self.autoinstall_config.get('interactive-sections')) + @web.middleware async def middleware(self, request, handler): - if self.updated: - updated = 'yes' - else: - updated = 'no' + override_status = None controller = await controller_for_request(request) if isinstance(controller, SubiquityController): + install_state = self.controllers.Install.install_state if not controller.interactive(): - return web.Response( - headers={'x-status': 'skip', 'x-updated': updated}) - elif self.base_model.needs_confirmation(controller.model_name): - return web.Response( - headers={'x-status': 'confirm', 'x-updated': updated}) - resp = await handler(request) - resp.headers['x-updated'] = updated + override_status = 'skip' + elif install_state == InstallState.NEEDS_CONFIRMATION: + if self.base_model.needs_configuration(controller.model_name): + override_status = 'confirm' + if override_status is not None: + resp = web.Response(headers={'x-status': override_status}) + else: + resp = await handler(request) + if self.updated: + resp.headers['x-updated'] = 'yes' + else: + resp.headers['x-updated'] = 'no' if resp.get('exception'): exc = resp['exception'] log.debug( @@ -109,6 +255,34 @@ class SubiquityServer(Application): ErrorReportRef, report.ref()) return resp + @with_context() + async def apply_autoinstall_config(self, context): + for controller in self.controllers.instances: + if controller.interactive(): + log.debug( + "apply_autoinstall_config: skipping %s as interactive", + controller.name) + continue + await controller.apply_autoinstall_config() + controller.configured() + + def load_autoinstall_config(self, only_early): + log.debug("load_autoinstall_config only_early %s", only_early) + if self.opts.autoinstall is None: + return + with open(self.opts.autoinstall) as fp: + self.autoinstall_config = yaml.safe_load(fp) + if only_early: + self.controllers.Reporting.setup_autoinstall() + self.controllers.Reporting.start() + self.controllers.Error.setup_autoinstall() + with self.context.child("core_validation", level="INFO"): + jsonschema.validate(self.autoinstall_config, self.base_schema) + self.controllers.Early.setup_autoinstall() + else: + for controller in self.controllers.instances: + controller.setup_autoinstall() + async def start_api_server(self): app = web.Application(middlewares=[self.middleware]) bind(app.router, API.meta, MetaController(self)) @@ -116,14 +290,42 @@ class SubiquityServer(Application): if self.opts.dry_run: from .dryrun import DryRunController bind(app.router, API.dry_run, DryRunController(self)) + for controller in self.controllers.instances: + controller.add_routes(app) runner = web.AppRunner(app) await runner.setup() site = web.UnixSite(runner, self.opts.socket) await site.start() async def start(self): - await super().start() + self.controllers.load_all() await self.start_api_server() + self.load_autoinstall_config(only_early=True) + if self.autoinstall_config and self.controllers.Early.cmds: + stamp_file = self.state_path("early-commands") + if not os.path.exists(stamp_file): + self.update_state(ApplicationState.EARLY_COMMANDS) + await self.controllers.Early.run() + open(stamp_file, 'w').close() + self.load_autoinstall_config(only_early=False) + if not self.interactive() and not self.opts.dry_run: + open('/run/casper-no-prompt', 'w').close() + self.load_serialized_state() + if self.interactive(): + self.update_state(ApplicationState.INTERACTIVE) + else: + self.update_state(ApplicationState.NON_INTERACTIVE) + await asyncio.sleep(1) + await super().start() + await self.apply_autoinstall_config() + + def _network_change(self): + self.signal.emit_signal('snapd-network-change') + + async def _proxy_set(self): + await run_in_thread( + self.snapd.connection.configure_proxy, self.base_model.proxy) + self.signal.emit_signal('snapd-network-change') def restart(self): cmdline = ['snap', 'run', 'subiquity'] @@ -132,3 +334,11 @@ class SubiquityServer(Application): sys.executable, '-m', 'subiquity.cmd.server', ] + sys.argv[1:] os.execvp(cmdline[0], cmdline) + + def make_autoinstall(self): + config = {'version': 1} + for controller in self.controllers.instances: + controller_conf = controller.make_autoinstall() + if controller_conf: + config[controller.autoinstall_key] = controller_conf + return config diff --git a/subiquitycore/core.py b/subiquitycore/core.py index 90b3aee4..9045d54e 100644 --- a/subiquitycore/core.py +++ b/subiquitycore/core.py @@ -129,12 +129,8 @@ class Application: def run(self): self.base_model = self.make_model() - try: - self.aio_loop.create_task(self.start()) - self.aio_loop.run_forever() - finally: - self.aio_loop.run_until_complete( - self.aio_loop.shutdown_asyncgens()) + self.aio_loop.create_task(self.start()) + self.aio_loop.run_forever() if self._exc: exc, self._exc = self._exc, None raise exc