diff --git a/subiquity/server/server.py b/subiquity/server/server.py index f88d5f425..e11489f78 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -668,9 +668,28 @@ def _read_config(self, *, cfg_path: str, context: Context) -> dict[str, Any]: with open(cfg_path) as fp: config: dict[str, Any] = yaml.safe_load(fp) - autoinstall_config: dict[str, Any] = dict() + autoinstall_config: dict[str, Any] + + # Support "autoinstall" as a top-level key + if "autoinstall" in config: + autoinstall_config = config.pop("autoinstall") + + # but the only top level key + if len(config) != 0: + self.interactive = bool(autoinstall_config.get("interactive-sections")) + msg: str = ( + "autoinstall.yaml is not a valid cloud config datasource.\n" + "No other keys may be present alongside 'autoinstall' at " + "the top level." + ) + context.error(msg) + raise AutoinstallValidationError( + owner="top-level keys", + details="autoinstall.yaml is not a valid cloud config datasource", + ) - autoinstall_config = config + else: + autoinstall_config = config return autoinstall_config diff --git a/subiquity/server/tests/test_server.py b/subiquity/server/tests/test_server.py index a6a199f3e..ec3069a55 100644 --- a/subiquity/server/tests/test_server.py +++ b/subiquity/server/tests/test_server.py @@ -19,6 +19,7 @@ from unittest.mock import AsyncMock, Mock, patch import jsonschema +import yaml from jsonschema.validators import validator_for from subiquity.common.types import NonReportableError, PasswordKind @@ -154,6 +155,7 @@ def test_early_commands_changes_autoinstall(self, validate_mock): class TestAutoinstallValidation(SubiTestCase): async def asyncSetUp(self): + self.tempdir = self.tmp_dir() opts = Mock() opts.dry_run = True opts.output_base = self.tmp_dir() @@ -169,6 +171,15 @@ async def asyncSetUp(self): } self.server.make_apport_report = Mock() + def path(self, relative_path): + return self.tmp_path(relative_path, dir=self.tempdir) + + def create(self, path, contents): + path = self.path(path) + with open(path, "w") as fp: + fp.write(contents) + return path + # Pseudo Load Controllers to avoid patching the loading logic for each # controller when we still want access to class attributes def pseudo_load_controllers(self): @@ -357,6 +368,44 @@ async def test_autoinstall_validation__filter_autoinstall(self, config, good, ba self.assertEqual(valid, good) self.assertEqual(invalid, bad) + async def test_autoinstall_validation__top_level_autoinstall(self): + """Test allow autoinstall as top-level key""" + + new_style = { + "autoinstall": { + "version": 1, + "interactive-sections": ["identity"], + "apt": "...", + } + } + old_style = new_style["autoinstall"] + + # Read new style correctly + path = self.create("autoinstall.yaml", yaml.dump(new_style)) + self.assertEqual(self.server._read_config(cfg_path=path), old_style) + + # No changes to old style + path = self.create("autoinstall.yaml", yaml.dump(old_style)) + self.assertEqual(self.server._read_config(cfg_path=path), old_style) + + async def test_autoinstall_validation__not_cloudinit_datasource(self): + """Test no cloud init datasources in new style autoinstall""" + + new_style = { + "autoinstall": { + "version": 1, + "interactive-sections": ["identity"], + "apt": "...", + }, + "cloudinit-data": "I am data", + } + + with self.assertRaises(AutoinstallValidationError) as ctx: + path = self.create("autoinstall.yaml", yaml.dump(new_style)) + self.server._read_config(cfg_path=path) + + self.assertEqual("top-level keys", ctx.exception.owner) + class TestMetaController(SubiTestCase): async def test_interactive_sections_not_present(self):