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:
parent
1dcb728c12
commit
32e7dc54c5
|
@ -24,6 +24,7 @@ from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
|
|
||||||
|
from subiquity.server.nonreportable import NonReportableException
|
||||||
from subiquitycore.models.network import NetDevInfo
|
from subiquitycore.models.network import NetDevInfo
|
||||||
|
|
||||||
|
|
||||||
|
@ -55,6 +56,21 @@ class ErrorReportRef:
|
||||||
oops_id: Optional[str]
|
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):
|
class ApplicationState(enum.Enum):
|
||||||
"""Represents the state of the application at a given time."""
|
"""Represents the state of the application at a given time."""
|
||||||
|
|
||||||
|
@ -87,6 +103,7 @@ class ApplicationStatus:
|
||||||
state: ApplicationState
|
state: ApplicationState
|
||||||
confirming_tty: str
|
confirming_tty: str
|
||||||
error: Optional[ErrorReportRef]
|
error: Optional[ErrorReportRef]
|
||||||
|
nonreportable_error: Optional[NonReportableError]
|
||||||
cloud_init_ok: Optional[bool]
|
cloud_init_ok: Optional[bool]
|
||||||
interactive: Optional[bool]
|
interactive: Optional[bool]
|
||||||
echo_syslog_id: str
|
echo_syslog_id: str
|
||||||
|
|
|
@ -38,6 +38,7 @@ from subiquity.common.types import (
|
||||||
ErrorReportRef,
|
ErrorReportRef,
|
||||||
KeyFingerprint,
|
KeyFingerprint,
|
||||||
LiveSessionSSHInfo,
|
LiveSessionSSHInfo,
|
||||||
|
NonReportableError,
|
||||||
PasswordKind,
|
PasswordKind,
|
||||||
)
|
)
|
||||||
from subiquity.models.subiquity import ModelNames, SubiquityModel
|
from subiquity.models.subiquity import ModelNames, SubiquityModel
|
||||||
|
@ -84,6 +85,7 @@ class MetaController:
|
||||||
state=self.app.state,
|
state=self.app.state,
|
||||||
confirming_tty=self.app.confirming_tty,
|
confirming_tty=self.app.confirming_tty,
|
||||||
error=self.app.fatal_error,
|
error=self.app.fatal_error,
|
||||||
|
nonreportable_error=self.app.nonreportable_error,
|
||||||
cloud_init_ok=self.app.cloud_init_ok,
|
cloud_init_ok=self.app.cloud_init_ok,
|
||||||
interactive=self.app.interactive,
|
interactive=self.app.interactive,
|
||||||
echo_syslog_id=self.app.echo_syslog_id,
|
echo_syslog_id=self.app.echo_syslog_id,
|
||||||
|
@ -292,6 +294,7 @@ class SubiquityServer(Application):
|
||||||
self.interactive = None
|
self.interactive = None
|
||||||
self.confirming_tty = ""
|
self.confirming_tty = ""
|
||||||
self.fatal_error: Optional[ErrorReport] = None
|
self.fatal_error: Optional[ErrorReport] = None
|
||||||
|
self.nonreportable_error: Optional[NonReportableError] = None
|
||||||
self.running_error_commands = False
|
self.running_error_commands = False
|
||||||
self.installer_user_name = None
|
self.installer_user_name = None
|
||||||
self.installer_user_passwd_kind = PasswordKind.NONE
|
self.installer_user_passwd_kind = PasswordKind.NONE
|
||||||
|
@ -431,7 +434,7 @@ class SubiquityServer(Application):
|
||||||
report: Optional[ErrorReport] = None
|
report: Optional[ErrorReport] = None
|
||||||
|
|
||||||
if isinstance(exc, NonReportableException):
|
if isinstance(exc, NonReportableException):
|
||||||
pass
|
self.nonreportable_error = NonReportableError.from_exception(exc)
|
||||||
else:
|
else:
|
||||||
report = self.error_reporter.report_for_exc(exc)
|
report = self.error_reporter.report_for_exc(exc)
|
||||||
if report is None:
|
if report is None:
|
||||||
|
|
|
@ -20,7 +20,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||||
import jsonschema
|
import jsonschema
|
||||||
from jsonschema.validators import validator_for
|
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.autoinstall import AutoinstallValidationError
|
||||||
from subiquity.server.nonreportable import NonReportableException
|
from subiquity.server.nonreportable import NonReportableException
|
||||||
from subiquity.server.server import (
|
from subiquity.server.server import (
|
||||||
|
@ -194,6 +194,9 @@ class TestAutoinstallValidation(SubiTestCase):
|
||||||
self.server._exception_handler(loop, context)
|
self.server._exception_handler(loop, context)
|
||||||
|
|
||||||
self.server.make_apport_report.assert_not_called()
|
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):
|
class TestMetaController(SubiTestCase):
|
||||||
|
@ -264,6 +267,8 @@ class TestExceptionHandling(SubiTestCase):
|
||||||
|
|
||||||
self.server.make_apport_report.assert_not_called()
|
self.server.make_apport_report.assert_not_called()
|
||||||
self.assertEqual(self.server.fatal_error, None)
|
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):
|
async def test_not_suppressed_apport_reporting(self):
|
||||||
"""Test apport reporting not suppressed"""
|
"""Test apport reporting not suppressed"""
|
||||||
|
@ -275,4 +280,5 @@ class TestExceptionHandling(SubiTestCase):
|
||||||
self.server._exception_handler(loop, context)
|
self.server._exception_handler(loop, context)
|
||||||
|
|
||||||
self.server.make_apport_report.assert_called()
|
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)
|
||||||
|
|
|
@ -118,7 +118,7 @@ class Client:
|
||||||
return (self.loads(content), resp)
|
return (self.loads(content), resp)
|
||||||
return self.loads(content)
|
return self.loads(content)
|
||||||
|
|
||||||
async def poll_startup(self):
|
async def poll_startup(self, allow_error: bool = False):
|
||||||
for _ in range(default_timeout * 10):
|
for _ in range(default_timeout * 10):
|
||||||
try:
|
try:
|
||||||
resp = await self.get("/meta/status")
|
resp = await self.get("/meta/status")
|
||||||
|
@ -129,7 +129,7 @@ class Client:
|
||||||
):
|
):
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
continue
|
continue
|
||||||
if resp["state"] == "ERROR":
|
if resp["state"] == "ERROR" and not allow_error:
|
||||||
raise Exception("server in error state")
|
raise Exception("server in error state")
|
||||||
return
|
return
|
||||||
except aiohttp.client_exceptions.ClientConnectorError:
|
except aiohttp.client_exceptions.ClientConnectorError:
|
||||||
|
@ -270,7 +270,7 @@ def tempdirs(*args, **kwargs):
|
||||||
|
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@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:
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
socket_path = f"{tempdir}/socket"
|
socket_path = f"{tempdir}/socket"
|
||||||
conn = aiohttp.UnixConnector(path=socket_path)
|
conn = aiohttp.UnixConnector(path=socket_path)
|
||||||
|
@ -279,7 +279,7 @@ async def start_server_factory(factory, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
await server.spawn(tempdir, socket_path, *args, **kwargs)
|
await server.spawn(tempdir, socket_path, *args, **kwargs)
|
||||||
await poll_for_socket_exist(socket_path)
|
await poll_for_socket_exist(socket_path)
|
||||||
await server.poll_startup()
|
await server.poll_startup(allow_error=allow_error)
|
||||||
yield server
|
yield server
|
||||||
finally:
|
finally:
|
||||||
await server.close()
|
await server.close()
|
||||||
|
@ -2032,6 +2032,53 @@ class TestAutoinstallServer(TestAPI):
|
||||||
)
|
)
|
||||||
self.assertTrue(expected.issubset(resp))
|
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):
|
class TestWSLSetupOptions(TestAPI):
|
||||||
@timeout()
|
@timeout()
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
identity:
|
||||||
|
realname: ''
|
||||||
|
hostname: ubuntu
|
||||||
|
username: ubuntu
|
||||||
|
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
|
||||||
|
|
||||||
|
|
||||||
|
early-commands:
|
||||||
|
- $((1/0))
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
version: 0
|
||||||
|
identity:
|
||||||
|
realname: ''
|
||||||
|
username: ubuntu
|
||||||
|
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
|
||||||
|
hostname: ubuntu
|
Loading…
Reference in New Issue