Skip to content

Commit

Permalink
AutoinstallValidationError: An error for autoinstall validation failures
Browse files Browse the repository at this point in the history
Adds a special exception for Autoinstall related failures and a
specific implementation for autoinstall validation failures.

When a user passes incorrect autoinstall data, the generated
bug report will be titled:

    "Autoinstall crashed with AutoinstallValidationError"

Where the traceback will start with a hint on the offending
section. For example:

    "Malformed autoinstall in 'apt' section"

Information from the original jsonschema ValidationError is
also available in the traceback to point to specific issues
with the provided data.

The section reporting is a little crude in the validation
of the base schema done by SubiquityServer, which can't discern
between the 'interative-sessions' and 'version' keys, but for now
the scope is pretty limited and can be fixed up at a later time.
  • Loading branch information
Chris-Peterson444 committed Jan 29, 2024
1 parent 98567a5 commit 378a373
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 4 deletions.
1 change: 1 addition & 0 deletions subiquity/common/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class ErrorReportState(enum.Enum):


class ErrorReportKind(enum.Enum):
AUTOINSTALL_FAIL = _("Autoinstall failure")
BLOCK_PROBE_FAIL = _("Block device probe failure")
DISK_PROBE_FAIL = _("Disk probe failure")
INSTALL_FAIL = _("Install failure")
Expand Down
50 changes: 50 additions & 0 deletions subiquity/server/autoinstall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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 logging

from jsonschema.exceptions import ValidationError

log = logging.getLogger("subiquity.server.autoinstall")


class AutoinstallError(Exception):
def __init__(
self,
message: str,
):
super().__init__(message)
self.message = message

def __repr__(self):
return f"<{self.__class__.__name__}: {self.message!r}>"

def __str__(self):
return self.__repr__()


class AutoinstallValidationError(AutoinstallError):
def __init__(
self,
owner: str,
error: ValidationError,
**kwargs,
):
self.message = f"Malformed autoinstall in {owner!r} section"
self.owner = owner
self.error = error
super().__init__(self.message, **kwargs)

def __str__(self):
return f"{self.message}\n\n{self.error}"
26 changes: 25 additions & 1 deletion subiquity/server/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
from typing import Any, Optional

import jsonschema
from jsonschema.exceptions import ValidationError

from subiquity.common.api.server import bind
from subiquity.common.types import ErrorReportKind
from subiquity.server.autoinstall import AutoinstallValidationError
from subiquity.server.types import InstallerChannels
from subiquitycore.context import with_context
from subiquitycore.controller import BaseController
Expand Down Expand Up @@ -54,6 +57,26 @@ async def _confirmed(self):
await self.configured()
self._active = False

def validate_autoinstall(self, ai_data: dict) -> None:
try:
jsonschema.validate(ai_data, self.autoinstall_schema)

except ValidationError as original_exception:
section = self.autoinstall_key

new_exception: AutoinstallValidationError = AutoinstallValidationError(
section,
original_exception,
)

self.app.make_apport_report(
ErrorReportKind.AUTOINSTALL_FAIL,
"Autoinstall",
exc=new_exception,
)

raise new_exception from original_exception

def setup_autoinstall(self):
if not self.app.autoinstall_config:
return
Expand All @@ -72,7 +95,8 @@ def setup_autoinstall(self):
ai_data = self.autoinstall_default

if ai_data is not None and self.autoinstall_schema is not None:
jsonschema.validate(ai_data, self.autoinstall_schema)
self.validate_autoinstall(ai_data)

self.load_autoinstall_data(ai_data)

def load_autoinstall_data(self, data):
Expand Down
26 changes: 24 additions & 2 deletions subiquity/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import yaml
from aiohttp import web
from cloudinit.config.cc_set_passwords import rand_user_password
from jsonschema.exceptions import ValidationError
from systemd import journal

from subiquity.cloudinit import get_host_combined_cloud_config
Expand All @@ -40,6 +41,7 @@
PasswordKind,
)
from subiquity.models.subiquity import ModelNames, SubiquityModel
from subiquity.server.autoinstall import AutoinstallValidationError
from subiquity.server.controller import SubiquityController
from subiquity.server.dryrun import DRConfig
from subiquity.server.errors import ErrorController
Expand Down Expand Up @@ -472,6 +474,27 @@ async def apply_autoinstall_config(self, context):
await controller.apply_autoinstall_config()
await controller.configured()

def validate_autoinstall(self):
with self.context.child("core_validation", level="INFO"):
try:
jsonschema.validate(self.autoinstall_config, self.base_schema)
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"
new_exception: AutoinstallValidationError = AutoinstallValidationError(
section,
original_exception,
)

self.make_apport_report(
ErrorReportKind.AUTOINSTALL_FAIL,
"Autoinstall",
exc=new_exception,
)

raise new_exception from original_exception

def load_autoinstall_config(self, *, only_early):
log.debug(
"load_autoinstall_config only_early %s file %s",
Expand All @@ -486,8 +509,7 @@ def load_autoinstall_config(self, *, only_early):
self.controllers.Reporting.setup_autoinstall()
self.controllers.Reporting.start()
self.controllers.Error.setup_autoinstall()
with self.context.child("core_validation", level="INFO"):
jsonschema.validate(self.autoinstall_config, self.base_schema)
self.validate_autoinstall()
self.controllers.Early.setup_autoinstall()
else:
for controller in self.controllers.instances:
Expand Down
34 changes: 34 additions & 0 deletions subiquity/server/tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import contextlib
from unittest.mock import patch

from subiquity.common.types import ErrorReportKind
from subiquity.server.autoinstall import AutoinstallValidationError
from subiquity.server.controller import SubiquityController
from subiquitycore.tests import SubiTestCase
from subiquitycore.tests.mocks import make_app
Expand Down Expand Up @@ -58,3 +60,35 @@ def test_setup_autoinstall(self, mock_load):
self.controller.autoinstall_default = "default-data"
self.controller.setup_autoinstall()
mock_load.assert_called_once_with("default-data")

def test_autoinstall_validation(self):
"""Test validation error type and apport reporting"""

self.controller.autoinstall_schema = {
"type": "object",
"properties": {
"some-key": {
"type": "boolean",
},
},
}

self.bad_ai_data = {"some-key": "not a bool"}

self.controller.autoinstall_key = "some-key"

# Assert error type is correct
with self.assertRaises(AutoinstallValidationError) as ctx:
self.controller.validate_autoinstall(self.bad_ai_data)

exception = ctx.exception

# Assert apport report is created with correct type
self.controller.app.make_apport_report.assert_called_with(
ErrorReportKind.AUTOINSTALL_FAIL,
"Autoinstall",
exc=exception,
)

# Assert error section is based on autoinstall_key
self.assertEquals(exception.owner, "some-key")
38 changes: 37 additions & 1 deletion subiquity/server/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
import jsonschema
from jsonschema.validators import validator_for

from subiquity.common.types import PasswordKind
from subiquity.common.types import ErrorReportKind, PasswordKind
from subiquity.server.autoinstall import AutoinstallValidationError
from subiquity.server.server import (
MetaController,
SubiquityServer,
Expand Down Expand Up @@ -151,6 +152,15 @@ async def asyncSetUp(self):
opts.output_base = self.tmp_dir()
opts.machine_config = "examples/machines/simple.json"
self.server = SubiquityServer(opts, None)
self.server.base_schema = {
"type": "object",
"properties": {
"some-key": {
"type": "boolean",
},
},
}
self.server.make_apport_report = Mock()

def test_valid_schema(self):
"""Test that the expected autoinstall JSON schema is valid"""
Expand All @@ -161,6 +171,32 @@ def test_valid_schema(self):

JsonValidator.check_schema(SubiquityServer.base_schema)

def test_autoinstall_validation__error_type(self):
"""Test that bad autoinstall data throws AutoinstallValidationError"""

bad_ai_data = {"some-key": "not a bool"}
self.server.autoinstall_config = bad_ai_data

with self.assertRaises(AutoinstallValidationError):
self.server.validate_autoinstall()

def test_autoinstall_validation__error_report_type(self):
"""Test correct apport reporting"""

bad_ai_data = {"some-key": "not a bool"}
self.server.autoinstall_config = bad_ai_data

with self.assertRaises(AutoinstallValidationError) as ctx:
self.server.validate_autoinstall()

exception = ctx.exception

self.server.make_apport_report.assert_called_with(
ErrorReportKind.AUTOINSTALL_FAIL,
"Autoinstall",
exc=exception,
)


class TestMetaController(SubiTestCase):
async def test_interactive_sections_not_present(self):
Expand Down
1 change: 1 addition & 0 deletions subiquitycore/tests/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@ def make_app(model=None):
app.log_syslog_id = None
app.report_start_event = mock.Mock()
app.report_finish_event = mock.Mock()
app.make_apport_report = mock.Mock()

return app

0 comments on commit 378a373

Please sign in to comment.