diff --git a/subiquity/client/client.py b/subiquity/client/client.py index 6da1285c9..88b7fde3d 100644 --- a/subiquity/client/client.py +++ b/subiquity/client/client.py @@ -31,11 +31,16 @@ 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 +from subiquity.common.types import ( + ApplicationState, + ErrorReportKind, + ErrorReportRef, + NonReportableError, +) from subiquity.journald import journald_listen from subiquity.server.server import POSTINSTALL_MODEL_NAMES from subiquity.ui.frame import SubiquityUI -from subiquity.ui.views.error import ErrorReportStretchy +from subiquity.ui.views.error import ErrorReportStretchy, NonReportableErrorStretchy from subiquity.ui.views.help import HelpMenu, ssh_help_texts from subiquity.ui.views.installprogress import InstallConfirmation from subiquity.ui.views.welcome import CloudInitFail @@ -317,30 +322,33 @@ def header_func(): status = await self.connect() self.interactive = status.interactive if self.interactive: - if self.opts.ssh: - ssh_info = await self.client.meta.ssh_info.GET() - texts = ssh_help_texts(ssh_info) - for line in texts: - import urwid - - if isinstance(line, urwid.Widget): - line = "\n".join( - [ - line.decode("utf-8").rstrip() - for line in line.render((1000,)).text - ] - ) - print(line) - return - - # Get the variant from the server and reload desired - # controllers if an override exists - variant = await self.client.meta.client_variant.GET() - if variant != self.variant: - self.variant = variant - controllers = self.variant_to_controllers.get(variant) - if controllers: - self.load_controllers(controllers) + # The server could end up in an error state before we get here + # so skip to allow urwid to come up and show an error screen + if status.state != ApplicationState.ERROR: + if self.opts.ssh: + ssh_info = await self.client.meta.ssh_info.GET() + texts = ssh_help_texts(ssh_info) + for line in texts: + import urwid + + if isinstance(line, urwid.Widget): + line = "\n".join( + [ + line.decode("utf-8").rstrip() + for line in line.render((1000,)).text + ] + ) + print(line) + return + + # Get the variant from the server and reload desired + # controllers if an override exists + variant = await self.client.meta.client_variant.GET() + if variant != self.variant: + self.variant = variant + controllers = self.variant_to_controllers.get(variant) + if controllers: + self.load_controllers(controllers) await super().start() # Progress uses systemd to collect and display the installation @@ -609,3 +617,7 @@ def show_error_report(self, error_ref): # Don't show an error if already looking at one. return self.add_global_overlay(ErrorReportStretchy(self, error_ref)) + + def show_nonreportable_error(self, error: NonReportableError): + log.debug("show_non_reportable_error %r", error.cause) + self.add_global_overlay(NonReportableErrorStretchy(self, error)) diff --git a/subiquity/client/controllers/progress.py b/subiquity/client/controllers/progress.py index 3e3a77054..0cb21b35b 100644 --- a/subiquity/client/controllers/progress.py +++ b/subiquity/client/controllers/progress.py @@ -15,11 +15,12 @@ import asyncio import logging +from typing import Optional import aiohttp from subiquity.client.controller import SubiquityTuiController -from subiquity.common.types import ApplicationState, ShutdownMode +from subiquity.common.types import ApplicationState, ApplicationStatus, ShutdownMode from subiquity.ui.views.installprogress import InstallRunning, ProgressView from subiquitycore.async_helpers import run_bg_task from subiquitycore.context import with_context @@ -32,6 +33,7 @@ def __init__(self, app): super().__init__(app) self.progress_view = ProgressView(self) self.app_state = None + self.has_nonreportable_error: Optional[bool] = None self.crash_report_ref = None self.answers = app.answers.get("InstallProgress", {}) @@ -75,16 +77,14 @@ async def _wait_status(self, context): await asyncio.sleep(1) continue self.app_state = app_status.state + self.has_nonreportable_error = app_status.nonreportable_error is not None self.progress_view.update_for_state(self.app_state) if self.ui.body is self.progress_view: self.ui.set_header(self.progress_view.title) - if app_status.error is not None: - if self.crash_report_ref is None: - self.crash_report_ref = app_status.error - self.ui.set_body(self.progress_view) - self.app.show_error_report(self.crash_report_ref) + if self.app_state == ApplicationState.ERROR: + self._handle_error_state(app_status) if self.app_state == ApplicationState.NEEDS_CONFIRMATION: if self.showing: @@ -105,6 +105,22 @@ async def _wait_status(self, context): if self.answers.get("reboot", False): self.click_reboot() + def _handle_error_state(self, app_status: ApplicationStatus): + if (error := app_status.error) is not None: + if self.crash_report_ref is None: + self.crash_report_ref = error + self.ui.set_body(self.progress_view) + self.app.show_error_report(error) + + elif (error := app_status.nonreportable_error) is not None: + self.ui.set_body(self.progress_view) + self.app.show_nonreportable_error(error) + + else: + # There is the case that both are None but still in an error state + # but this is likely a bug so raise an Exception + raise Exception("Server in ERROR state but no error received") + def make_ui(self): if self.app_state == ApplicationState.NEEDS_CONFIRMATION: self.app.show_confirm_install() diff --git a/subiquity/client/controllers/tests/test_progress.py b/subiquity/client/controllers/tests/test_progress.py new file mode 100644 index 000000000..63bd39632 --- /dev/null +++ b/subiquity/client/controllers/tests/test_progress.py @@ -0,0 +1,95 @@ +# Copyright 2024 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 unittest +from unittest.mock import AsyncMock, Mock + +from subiquity.client.controllers.progress import ProgressController +from subiquity.common.types import ApplicationState, ApplicationStatus +from subiquitycore.tests.mocks import make_app + + +class TestProgressController(unittest.IsolatedAsyncioTestCase): + def setUp(self): + app = make_app() + app.client = AsyncMock() + app.show_error_report = Mock() + app.show_nonreportable_error = Mock() + + self.controller = ProgressController(app) + + async def test_handle_error_state(self): + # Reportable error case + + status: ApplicationStatus = ApplicationStatus( + state=ApplicationState.ERROR, + confirming_tty="", + error=Mock(), + nonreportable_error=None, + cloud_init_ok=Mock(), + interactive=Mock(), + echo_syslog_id=Mock(), + log_syslog_id=Mock(), + event_syslog_id=Mock(), + ) + + self.controller._handle_error_state(status) + self.controller.app.show_error_report.assert_called_once() + self.controller.app.show_nonreportable_error.assert_not_called() + + # Reset mocks between cases + self.controller.app.show_error_report.reset_mock() + self.controller.app.show_nonreportable_error.reset_mock() + + # Non Reportable error case + + status: ApplicationStatus = ApplicationStatus( + state=ApplicationState.ERROR, + confirming_tty="", + error=None, + nonreportable_error=Mock(), + cloud_init_ok=Mock(), + interactive=Mock(), + echo_syslog_id=Mock(), + log_syslog_id=Mock(), + event_syslog_id=Mock(), + ) + + self.controller._handle_error_state(status) + self.controller.app.show_error_report.assert_not_called() + self.controller.app.show_nonreportable_error.assert_called_once() + + # Reset mocks between cases + self.controller.app.show_error_report.reset_mock() + self.controller.app.show_nonreportable_error.reset_mock() + + # Bug case + + status: ApplicationStatus = ApplicationStatus( + state=ApplicationState.ERROR, + confirming_tty="", + error=None, + nonreportable_error=None, + cloud_init_ok=Mock(), + interactive=Mock(), + echo_syslog_id=Mock(), + log_syslog_id=Mock(), + event_syslog_id=Mock(), + ) + + with self.assertRaises(Exception): + self.controller._handle_error_state(status) + self.controller.app.show_error_report.assert_not_called() + self.controller.app.show_nonreportable_error.assert_not_called() diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 7a7b10e15..6e71246d8 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -121,6 +121,7 @@ def POST(tty: str) -> None: """Confirm that the installation should proceed.""" class restart: + @allowed_before_start def POST() -> None: """Restart the server process.""" diff --git a/subiquity/common/types.py b/subiquity/common/types.py index b7e3bf0c5..4c5ae938e 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -24,6 +24,7 @@ import attr +from subiquity.server.nonreportable import NonReportableException from subiquitycore.models.network import NetDevInfo @@ -55,6 +56,21 @@ class ErrorReportRef: oops_id: Optional[str] +@attr.s(auto_attribs=True) +class NonReportableError: + cause: str + message: str + details: Optional[str] + + @classmethod + def from_exception(cls, exc: NonReportableException): + return cls( + cause=type(exc).__name__, + message=str(exc), + details=exc.details, + ) + + class ApplicationState(enum.Enum): """Represents the state of the application at a given time.""" @@ -87,6 +103,7 @@ class ApplicationStatus: state: ApplicationState confirming_tty: str error: Optional[ErrorReportRef] + nonreportable_error: Optional[NonReportableError] cloud_init_ok: Optional[bool] interactive: Optional[bool] echo_syslog_id: str diff --git a/subiquity/server/autoinstall.py b/subiquity/server/autoinstall.py index 0abc3dd37..149215269 100644 --- a/subiquity/server/autoinstall.py +++ b/subiquity/server/autoinstall.py @@ -13,11 +13,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import logging +from typing import Optional + +from subiquity.server.nonreportable import NonReportableException log = logging.getLogger("subiquity.server.autoinstall") -class AutoinstallError(Exception): +class AutoinstallError(NonReportableException): pass @@ -25,7 +28,8 @@ class AutoinstallValidationError(AutoinstallError): def __init__( self, owner: str, + details: Optional[str] = None, ): self.message = f"Malformed autoinstall in {owner!r} section" self.owner = owner - super().__init__(self.message) + super().__init__(self.message, details=details) diff --git a/subiquity/server/nonreportable.py b/subiquity/server/nonreportable.py new file mode 100644 index 000000000..a6dce1cc4 --- /dev/null +++ b/subiquity/server/nonreportable.py @@ -0,0 +1,25 @@ +# Copyright 2024 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 logging +from typing import Optional + +log = logging.getLogger("subiquity.server.nonreportable") + + +class NonReportableException(Exception): + def __init__(self, message: str, details: Optional[str] = None): + self.message: str = message + self.details: Optional[str] = None + super().__init__(message) diff --git a/subiquity/server/server.py b/subiquity/server/server.py index 5c12adb55..e0e5ac17b 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -38,14 +38,16 @@ ErrorReportRef, KeyFingerprint, LiveSessionSSHInfo, + NonReportableError, PasswordKind, ) from subiquity.models.subiquity import ModelNames, SubiquityModel -from subiquity.server.autoinstall import AutoinstallError, AutoinstallValidationError +from subiquity.server.autoinstall import AutoinstallValidationError from subiquity.server.controller import SubiquityController from subiquity.server.dryrun import DRConfig from subiquity.server.errors import ErrorController from subiquity.server.geoip import DryRunGeoIPStrategy, GeoIP, HTTPGeoIPStrategy +from subiquity.server.nonreportable import NonReportableException from subiquity.server.pkghelper import get_package_installer from subiquity.server.runner import get_command_runner from subiquity.server.snapdapi import make_api_client @@ -83,6 +85,7 @@ async def status_GET( state=self.app.state, confirming_tty=self.app.confirming_tty, error=self.app.fatal_error, + nonreportable_error=self.app.nonreportable_error, cloud_init_ok=self.app.cloud_init_ok, interactive=self.app.interactive, echo_syslog_id=self.app.echo_syslog_id, @@ -290,7 +293,8 @@ def __init__(self, opts, block_log_dir): self.update_state(ApplicationState.STARTING_UP) self.interactive = None self.confirming_tty = "" - self.fatal_error = None + self.fatal_error: Optional[ErrorReport] = None + self.nonreportable_error: Optional[NonReportableError] = None self.running_error_commands = False self.installer_user_name = None self.installer_user_passwd_kind = PasswordKind.NONE @@ -415,16 +419,29 @@ async def _run_error_cmds(self, report: Optional[ErrorReport] = None) -> None: self.update_state(ApplicationState.ERROR) def _exception_handler(self, loop, context): - exc = context.get("exception") + exc: Optional[Exception] = context.get("exception") if exc is None: super()._exception_handler(loop, context) return - report = self.error_reporter.report_for_exc(exc) log.error("top level error", exc_info=exc) - if not isinstance(exc, AutoinstallError) and not report: - report = self.make_apport_report( - ErrorReportKind.UNKNOWN, "unknown error", exc=exc - ) + + # Some common errors have apport reports written closer to where the + # exception was originally thrown. We write a generic "unknown error" + # report for cases where it wasn't written already, except in cases + # where we want to explicitly supress report generation (e.g., bad + # autoinstall cases). + + report: Optional[ErrorReport] = None + + if isinstance(exc, NonReportableException): + self.nonreportable_error = NonReportableError.from_exception(exc) + else: + report = self.error_reporter.report_for_exc(exc) + if report is None: + report = self.make_apport_report( + ErrorReportKind.UNKNOWN, "unknown error", exc=exc + ) + self.fatal_error = report if self.interactive: self.update_state(ApplicationState.ERROR) @@ -481,7 +498,7 @@ def validate_autoinstall(self): except ValidationError as original_exception: # SubiquityServer currently only checks for these sections # of autoinstall. Hardcode until we have better validation. - section = "version or interative-sessions" + section = "version or interactive-sections" new_exception: AutoinstallValidationError = AutoinstallValidationError( section, ) @@ -494,10 +511,27 @@ def load_autoinstall_config(self, *, only_early): only_early, self.autoinstall, ) + + # Set the interactivity as early as possible so autoinstall validation + # errors can be shown to the user in an interactive way, if applicable. + # + # In the case of no autoinstall data, we set interactive=true. + # + # Otherwise, we need to check the interactivity of the session on both + # calls (early=True and early=False) because it's possible that an + # early command mutates the autoinstall and changes the value of + # interactive-sections. + if not self.autoinstall: + self.interactive = True return + with open(self.autoinstall) as fp: self.autoinstall_config = yaml.safe_load(fp) + + # Check every time + self.interactive = bool(self.autoinstall_config.get("interactive-sections")) + if only_early: self.controllers.Reporting.setup_autoinstall() self.controllers.Reporting.start() @@ -700,10 +734,6 @@ async def start(self): open(stamp_file, "w").close() await asyncio.sleep(1) self.load_autoinstall_config(only_early=False) - if self.autoinstall_config: - self.interactive = bool(self.autoinstall_config.get("interactive-sections")) - else: - self.interactive = True if not self.interactive and not self.opts.dry_run: open("/run/casper-no-prompt", "w").close() self.load_serialized_state() diff --git a/subiquity/server/tests/test_server.py b/subiquity/server/tests/test_server.py index 0d039ae9f..4fd562afc 100644 --- a/subiquity/server/tests/test_server.py +++ b/subiquity/server/tests/test_server.py @@ -20,8 +20,9 @@ import jsonschema from jsonschema.validators import validator_for -from subiquity.common.types import PasswordKind +from subiquity.common.types import NonReportableError, PasswordKind from subiquity.server.autoinstall import AutoinstallValidationError +from subiquity.server.nonreportable import NonReportableException from subiquity.server.server import ( MetaController, SubiquityServer, @@ -224,6 +225,9 @@ async def test_autoinstall_validation__no_error_report(self): self.server._exception_handler(loop, context) self.server.make_apport_report.assert_not_called() + self.assertIsNone(self.server.fatal_error) + error = NonReportableError.from_exception(exception) + self.assertEqual(error, self.server.nonreportable_error) class TestMetaController(SubiTestCase): @@ -270,3 +274,42 @@ async def test_no_default_user(self): server.set_installer_password() self.assertIsNone(server.installer_user_name) self.assertEqual(PasswordKind.NONE, server.installer_user_passwd_kind) + + +class TestExceptionHandling(SubiTestCase): + async def asyncSetUp(self): + opts = Mock() + opts.dry_run = True + opts.output_base = self.tmp_dir() + opts.machine_config = "examples/machines/simple.json" + self.server = SubiquityServer(opts, None) + self.server._run_error_cmds = AsyncMock() + self.server.make_apport_report = Mock() + + async def test_suppressed_apport_reporting(self): + """Test apport reporting suppressed""" + + MockException = type("MockException", (NonReportableException,), {}) + exception = MockException("Don't report me") + loop = Mock() + context = {"exception": exception} + + self.server._exception_handler(loop, context) + + self.server.make_apport_report.assert_not_called() + self.assertEqual(self.server.fatal_error, None) + error = NonReportableError.from_exception(exception) + self.assertEqual(error, self.server.nonreportable_error) + + async def test_not_suppressed_apport_reporting(self): + """Test apport reporting not suppressed""" + + exception = Exception("Report me") + loop = Mock() + context = {"exception": exception} + + self.server._exception_handler(loop, context) + + self.server.make_apport_report.assert_called() + self.assertIsNotNone(self.server.fatal_error) + self.assertIsNone(self.server.nonreportable_error) diff --git a/subiquity/tests/api/test_api.py b/subiquity/tests/api/test_api.py index 8bce77fb0..2d9f33c1b 100644 --- a/subiquity/tests/api/test_api.py +++ b/subiquity/tests/api/test_api.py @@ -118,7 +118,7 @@ async def request( return (self.loads(content), resp) return self.loads(content) - async def poll_startup(self): + async def poll_startup(self, allow_error: bool = False): for _ in range(default_timeout * 10): try: resp = await self.get("/meta/status") @@ -129,7 +129,7 @@ async def poll_startup(self): ): await asyncio.sleep(0.5) continue - if resp["state"] == "ERROR": + if resp["state"] == "ERROR" and not allow_error: raise Exception("server in error state") return except aiohttp.client_exceptions.ClientConnectorError: @@ -270,7 +270,7 @@ def tempdirs(*args, **kwargs): @contextlib.asynccontextmanager -async def start_server_factory(factory, *args, **kwargs): +async def start_server_factory(factory, *args, allow_error: bool = False, **kwargs): with tempfile.TemporaryDirectory() as tempdir: socket_path = f"{tempdir}/socket" conn = aiohttp.UnixConnector(path=socket_path) @@ -279,7 +279,7 @@ async def start_server_factory(factory, *args, **kwargs): try: await server.spawn(tempdir, socket_path, *args, **kwargs) await poll_for_socket_exist(socket_path) - await server.poll_startup() + await server.poll_startup(allow_error=allow_error) yield server finally: await server.close() @@ -2032,6 +2032,53 @@ async def test_interactive(self): ) self.assertTrue(expected.issubset(resp)) + async def test_autoinstall_validation_error(self): + cfg = "examples/machines/simple.json" + extra = [ + "--autoinstall", + "test_data/autoinstall/invalid-early.yaml", + ] + # bare server factory for early fail + async with start_server_factory( + Server, cfg, extra_args=extra, allow_error=True + ) as inst: + resp = await inst.get("/meta/status") + + error = resp["nonreportable_error"] + self.assertIsNone(resp["error"]) + + self.assertIsNotNone(error) + self.assertIn("cause", error) + self.assertIn("message", error) + self.assertIn("details", error) + self.assertEqual(error["cause"], "AutoinstallValidationError") + + # This test isn't perfect, because in the future we should + # really throw an AutoinstallError when a user provided + # command fails, but this is the simplest way to test + # the non-reportable errors are still reported correctly. + # This has the added bonus of failing in the future when + # we want to implement this behavior in the command + # controllers + async def test_autoinstall_not_autoinstall_error(self): + cfg = "examples/machines/simple.json" + extra = [ + "--autoinstall", + "test_data/autoinstall/bad-early-command.yaml", + ] + # bare server factory for early fail + async with start_server_factory( + Server, cfg, extra_args=extra, allow_error=True + ) as inst: + resp = await inst.get("/meta/status") + + error = resp["error"] + self.assertIsNone(resp["nonreportable_error"]) + + self.assertIsNotNone(error) + self.assertNotEqual(error, None) + self.assertEqual(error["kind"], "UNKNOWN") + class TestWSLSetupOptions(TestAPI): @timeout() diff --git a/subiquity/ui/views/error.py b/subiquity/ui/views/error.py index 13453185a..a5acaa7e8 100644 --- a/subiquity/ui/views/error.py +++ b/subiquity/ui/views/error.py @@ -16,10 +16,10 @@ import asyncio import logging -from urwid import Padding, ProgressBar, Text, connect_signal, disconnect_signal +from urwid import AttrMap, Padding, ProgressBar, Text, connect_signal, disconnect_signal from subiquity.common.errorreport import ErrorReportKind, ErrorReportState -from subiquity.common.types import CasperMd5Results +from subiquity.common.types import CasperMd5Results, NonReportableError from subiquitycore.async_helpers import run_bg_task from subiquitycore.ui.buttons import other_btn from subiquitycore.ui.container import Pile @@ -421,3 +421,90 @@ def _report_changed(self, report): for (s1, old_c), new_c in zip(old_r.cells, new_cells): old_c.set_text(new_c.text) self.table.invalidate() + + +nonreportable_titles: dict[str, str] = { + "AutoinstallError": _("an Autoinstall error"), + "AutoinstallValidationError": _("an Autoinstall validation error"), +} + +nonreportable_footers: dict[str, str] = { + "AutoinstallError": _( + "The installation will be unable to proceed with the provided " + "Autoinstall file. Please modify it and try again." + ), + "AutoinstallValidationError": _( + "The installer has detected an issue with the provided Autoinstall " + "file. Please modify it and try again." + ), +} + + +class NonReportableErrorStretchy(Stretchy): + def __init__(self, app, error): + self.app = app # A SubiquityClient + self.error: NonReportableError = error + + self.btns: dict[str, AttrMap] = { + "close": close_btn(self, _("Close")), + "debug_shell": other_btn(_("Switch to a shell"), on_press=self.debug_shell), + "restart": other_btn(_("Restart the installer"), on_press=self.restart), + } + # Get max button width and create even button sizes + width: int = 0 + width = max((widget_width(button) for button in self.btns.values())) + for name, button in self.btns.items(): + self.btns[name] = Padding(button, width=width, align="center") + + self.pile: Pile = Pile([]) + self.pile.contents[:] = [ + (widget, self.pile.options("pack")) for widget in self._pile_elements() + ] + super().__init__("", [self.pile], 0, 0) + + def _pile_elements(self): + btns: dict[str, AttrMap] = self.btns.copy() + + cause: str = self.error.cause # An exception type name + + # Title + title_prefix: str = _("The installation has halted due to") + reason: str = nonreportable_titles[cause] # no default, bug if undefined + widgets: list[Text | AttrMap] = [ + Text(rewrap(f"{title_prefix} {reason}.")), + Text(""), + ] + + summary_prefix: str = _("error") + # Error Summary + widgets.extend( + [ + Text(rewrap(f"{summary_prefix}: {self.error.message}")), + Text(""), + ] + ) + + # Footer and Buttons + footer_text_default: str = _( + "The installation is unable to be completed. You may " + "switch to a shell to inspect the situation or restart " + "the installer to try again." + ) + footer_text: str = nonreportable_footers.get(cause, footer_text_default) + widgets.extend( + [ + Text(rewrap(footer_text)), + Text(""), + btns["debug_shell"], + btns["restart"], + btns["close"], + ] + ) + + return widgets + + def debug_shell(self, sender): + self.app.debug_shell() + + def restart(self, sender): + self.app.restart(restart_server=True) diff --git a/subiquity/ui/views/installprogress.py b/subiquity/ui/views/installprogress.py index e43f6a22f..253316116 100644 --- a/subiquity/ui/views/installprogress.py +++ b/subiquity/ui/views/installprogress.py @@ -48,6 +48,9 @@ def __init__(self, controller): self.ongoing = {} # context_id -> line containing a spinner self.reboot_btn = Toggleable(ok_btn(_("Reboot Now"), on_press=self.reboot)) + self.restart_btn = Toggleable( + ok_btn(_("Restart Installer"), on_press=self.restart) + ) self.view_error_btn = cancel_btn( _("View error report"), on_press=self.view_error ) @@ -172,13 +175,21 @@ def update_for_state(self, state): ] elif state == ApplicationState.ERROR: self.title = _("An error occurred during installation") - self.reboot_btn.base_widget.set_label(_("Reboot Now")) - self.reboot_btn.enabled = True - btns = [ - self.view_log_btn, - self.view_error_btn, - self.reboot_btn, - ] + + if self.controller.has_nonreportable_error: + self.view_error_btn.enable = False + btns = [ + self.view_log_btn, + self.restart_btn, + ] + else: + self.reboot_btn.base_widget.set_label(_("Reboot Now")) + self.reboot_btn.enabled = True + btns = [ + self.view_log_btn, + self.view_error_btn, + self.reboot_btn, + ] elif state == ApplicationState.EXITED: self.title = _("Subiquity server process has exited") btns = [self.view_log_btn] @@ -214,6 +225,9 @@ def reboot(self, btn): self.controller.click_reboot() self._set_button_width() + def restart(self, sender): + self.controller.app.restart(restart_server=True) + def view_error(self, btn): self.controller.app.show_error_report(self.controller.crash_report_ref) diff --git a/subiquity/ui/views/tests/test_installprogress.py b/subiquity/ui/views/tests/test_installprogress.py index e8e2b5029..f16fca43e 100644 --- a/subiquity/ui/views/tests/test_installprogress.py +++ b/subiquity/ui/views/tests/test_installprogress.py @@ -30,3 +30,26 @@ def test_show_complete(self): self.assertIsNot(btn, None) view_helpers.click(btn) view.controller.click_reboot.assert_called_once_with() + + def test_error_disambiguation(self): + view = self.make_view() + + # Reportable errors + view.controller.has_nonreportable_error = False + view.update_for_state(ApplicationState.ERROR) + btn = view_helpers.find_button_matching(view, "^View error report$") + self.assertIsNotNone(btn) + btn = view_helpers.find_button_matching(view, "^Reboot Now$") + self.assertIsNotNone(btn) + btn = view_helpers.find_button_matching(view, "^Restart Installer$") + self.assertIsNone(btn) + + # Non-Reportable errors + view.controller.has_nonreportable_error = True + view.update_for_state(ApplicationState.ERROR) + btn = view_helpers.find_button_matching(view, "^View error report$") + self.assertIsNone(btn) + btn = view_helpers.find_button_matching(view, "^Reboot Now$") + self.assertIsNone(btn) + btn = view_helpers.find_button_matching(view, "^Restart Installer$") + self.assertIsNotNone(btn) diff --git a/test_data/autoinstall/bad-early-command.yaml b/test_data/autoinstall/bad-early-command.yaml new file mode 100644 index 000000000..14ed62145 --- /dev/null +++ b/test_data/autoinstall/bad-early-command.yaml @@ -0,0 +1,12 @@ +version: 1 + +identity: + realname: '' + hostname: ubuntu + username: ubuntu + password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1' + + +early-commands: + - $((1/0)) + diff --git a/test_data/autoinstall/invalid-early.yaml b/test_data/autoinstall/invalid-early.yaml new file mode 100644 index 000000000..3a24d9df9 --- /dev/null +++ b/test_data/autoinstall/invalid-early.yaml @@ -0,0 +1,6 @@ +version: 0 +identity: + realname: '' + username: ubuntu + password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1' + hostname: ubuntu