diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 712f2699c..e1e3dde83 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -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") diff --git a/subiquity/server/autoinstall.py b/subiquity/server/autoinstall.py new file mode 100644 index 000000000..0e7fe192b --- /dev/null +++ b/subiquity/server/autoinstall.py @@ -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 . +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}" diff --git a/subiquity/server/controller.py b/subiquity/server/controller.py index 4662f1877..829c24ac5 100644 --- a/subiquity/server/controller.py +++ b/subiquity/server/controller.py @@ -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 @@ -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 @@ -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): diff --git a/subiquity/server/server.py b/subiquity/server/server.py index 99e65aca3..6ad59b2c3 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -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 @@ -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 @@ -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", @@ -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: diff --git a/subiquity/server/tests/test_controller.py b/subiquity/server/tests/test_controller.py index b5af4fbbf..d5dace7ca 100644 --- a/subiquity/server/tests/test_controller.py +++ b/subiquity/server/tests/test_controller.py @@ -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 @@ -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") diff --git a/subiquity/server/tests/test_server.py b/subiquity/server/tests/test_server.py index 4193abcd3..50ab48677 100644 --- a/subiquity/server/tests/test_server.py +++ b/subiquity/server/tests/test_server.py @@ -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, @@ -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""" @@ -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): diff --git a/subiquitycore/tests/mocks.py b/subiquitycore/tests/mocks.py index 89456ac6d..748b97b55 100644 --- a/subiquitycore/tests/mocks.py +++ b/subiquitycore/tests/mocks.py @@ -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