API: Add non-reportable errors to /meta/status API response

Adds a field to the ApplicationStatus struct, nonreportable_error,
to be filled when the server enters an error state due to a
non-reportable error/exception type.
This commit is contained in:
Chris Peterson 2024-03-01 15:12:03 -08:00
parent 1dcb728c12
commit 32e7dc54c5
6 changed files with 98 additions and 7 deletions

View File

@ -24,6 +24,7 @@ from typing import Any, Dict, List, Optional, Union
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

View File

@ -38,6 +38,7 @@ from subiquity.common.types import (
ErrorReportRef,
KeyFingerprint,
LiveSessionSSHInfo,
NonReportableError,
PasswordKind,
)
from subiquity.models.subiquity import ModelNames, SubiquityModel
@ -84,6 +85,7 @@ class MetaController:
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,
@ -292,6 +294,7 @@ class SubiquityServer(Application):
self.interactive = None
self.confirming_tty = ""
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
@ -431,7 +434,7 @@ class SubiquityServer(Application):
report: Optional[ErrorReport] = None
if isinstance(exc, NonReportableException):
pass
self.nonreportable_error = NonReportableError.from_exception(exc)
else:
report = self.error_reporter.report_for_exc(exc)
if report is None:

View File

@ -20,7 +20,7 @@ from unittest.mock import AsyncMock, Mock, patch
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 (
@ -194,6 +194,9 @@ class TestAutoinstallValidation(SubiTestCase):
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):
@ -264,6 +267,8 @@ class TestExceptionHandling(SubiTestCase):
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"""
@ -275,4 +280,5 @@ class TestExceptionHandling(SubiTestCase):
self.server._exception_handler(loop, context)
self.server.make_apport_report.assert_called()
self.assertNotEqual(self.server.fatal_error, None)
self.assertIsNotNone(self.server.fatal_error)
self.assertIsNone(self.server.nonreportable_error)

View File

@ -118,7 +118,7 @@ class Client:
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 @@ class Client:
):
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 @@ class TestAutoinstallServer(TestAPI):
)
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()

View File

@ -0,0 +1,12 @@
version: 1
identity:
realname: ''
hostname: ubuntu
username: ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
early-commands:
- $((1/0))

View File

@ -0,0 +1,6 @@
version: 0
identity:
realname: ''
username: ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
hostname: ubuntu