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