Skip to content

Commit

Permalink
client: Add support for non-reportable errors
Browse files Browse the repository at this point in the history
Adds support for AutoinstallValidation errors, the first class
of non-reportable errors. Includes a separate error overaly to
display a warning to the user about the issue.

Changes to the server to allow restarting the installer before all
of the controllers are loaded, since the error means the controllers
won't ever be loaded. Adds special handling to the ProgressView to
change the Reboot (the machine) button to a Restart (the installer) button
for this case.
  • Loading branch information
Chris-Peterson444 committed Mar 5, 2024
1 parent 0207dee commit 09ef152
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 17 deletions.
13 changes: 11 additions & 2 deletions subiquity/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -612,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))
28 changes: 22 additions & 6 deletions subiquity/client/controllers/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ def __init__(self, app):
super().__init__(app)
self.progress_view = ProgressView(self)
self.app_state = None
self.error_is_not_reportable: bool = Optional[None]
self.crash_report_ref = None
self.answers = app.answers.get("InstallProgress", {})

Expand Down Expand Up @@ -75,16 +77,14 @@ async def _wait_status(self, context):
await asyncio.sleep(1)
continue
self.app_state = app_status.state
self.error_is_not_reportable = 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:
Expand All @@ -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()
Expand Down
95 changes: 95 additions & 0 deletions subiquity/client/controllers/tests/test_progress.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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()
1 change: 1 addition & 0 deletions subiquity/common/apidef.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
91 changes: 89 additions & 2 deletions subiquity/ui/views/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
28 changes: 21 additions & 7 deletions subiquity/ui/views/installprogress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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.error_is_not_reportable:
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]
Expand Down Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions subiquity/ui/views/tests/test_installprogress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.error_is_not_reportable = 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.error_is_not_reportable = 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)

0 comments on commit 09ef152

Please sign in to comment.