diff --git a/autoinstall-schema.json b/autoinstall-schema.json index 9fa31681..3781d033 100644 --- a/autoinstall-schema.json +++ b/autoinstall-schema.json @@ -386,6 +386,14 @@ "pin-priority" ] } + }, + "fallback": { + "type": "string", + "enum": [ + "abort", + "continue-anyway", + "offline-install" + ] } } }, diff --git a/documentation/autoinstall-reference.md b/documentation/autoinstall-reference.md index 2e7e4120..1f364be0 100644 --- a/documentation/autoinstall-reference.md +++ b/documentation/autoinstall-reference.md @@ -201,6 +201,7 @@ Apt configuration, used both during the install and once booted into the target This section historically used the same format as curtin, [which is documented here](https://curtin.readthedocs.io/en/latest/topics/apt_source.html). Nonetheless, some key differences with the format supported by curtin have been introduced: * Subiquity supports an alternative format for the `primary` section, allowing to configure a list of candidate primary mirrors. During installation, subiquity will automatically test the specified mirrors and select the first one that seems usable. This new behavior is only activated when the `primary` section is wrapped in the `mirror-selection` section. + * The `fallback` key controls what subiquity should do if no primary mirror is usable. * The `geoip` key controls whether a geoip lookup is done to determine the correct country mirror. The default is: @@ -214,6 +215,7 @@ The default is: uri: "http://archive.ubuntu.com/ubuntu" - arches: [s390x, arm64, armhf, powerpc, ppc64el, riscv64] uri: "http://ports.ubuntu.com/ubuntu-ports" + fallback: abort geoip: true #### mirror-selection @@ -229,6 +231,17 @@ In the new format, the `primary` section expects a list of mirrors, which can be * `uri`: the URI of the mirror to use, e.g., "http://fr.archive.ubuntu.com/ubuntu" * `arches`: an optional list of architectures supported by the mirror. By default, this list contains the current CPU architecture. +#### fallback +**type:** string (enumeration) +**default:** abort + +Controls what subiquity should do if no primary mirror is usable. +Supported values are: + + * `abort` -> abort the installation + * `offline-install` -> revert to an offline installation + * `continue-anyway` -> attempt to install the system anyway (not recommended, the installation will certainly fail) + #### geoip **type:** boolean **default:**: `true` diff --git a/documentation/autoinstall-schema.md b/documentation/autoinstall-schema.md index 0820b569..159b5551 100644 --- a/documentation/autoinstall-schema.md +++ b/documentation/autoinstall-schema.md @@ -408,6 +408,14 @@ The [JSON schema](https://json-schema.org/) for autoinstall data is as follows: "pin-priority" ] } + }, + "fallback": { + "type": "string", + "enum": [ + "abort", + "continue-anyway", + "offline-install" + ] } } }, diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 382a23da..a6667ae3 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -43,6 +43,7 @@ from subiquity.common.types import ( KeyboardSetup, IdentityData, NetworkStatus, + MirrorSelectionFallback, MirrorGet, MirrorPost, MirrorPostResponse, @@ -370,6 +371,8 @@ class API: class abort: def POST() -> None: ... + fallback = simple_endpoint(MirrorSelectionFallback) + class ubuntu_pro: def GET() -> UbuntuProResponse: ... def POST(data: Payload[UbuntuProInfo]) -> None: ... diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 094ad7f0..d58f75a9 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -766,6 +766,12 @@ class MirrorGet: staged: Optional[str] +class MirrorSelectionFallback(enum.Enum): + ABORT = 'abort' + CONTINUE_ANYWAY = 'continue-anyway' + OFFLINE_INSTALL = 'offline-install' + + @attr.s(auto_attribs=True) class ADConnectionInfo: admin_name: str = "" diff --git a/subiquity/models/mirror.py b/subiquity/models/mirror.py index 765a2fb9..db27f787 100644 --- a/subiquity/models/mirror.py +++ b/subiquity/models/mirror.py @@ -83,6 +83,8 @@ from urllib import parse import attr +from subiquity.common.types import MirrorSelectionFallback + from curtin.commands.apt_config import ( get_arch_mirrorconfig, get_mirror, @@ -255,6 +257,9 @@ class MirrorModel(object): self.default_mirror = \ LegacyPrimaryEntry.new_from_default(parent=self).uri + # What to do if automatic mirror-selection fails. + self.fallback = MirrorSelectionFallback.ABORT + def _default_primary_entries(self) -> List[PrimaryEntry]: return [ PrimaryEntry(parent=self, country_mirror=True), @@ -294,12 +299,15 @@ class MirrorModel(object): entry = PrimaryEntry.from_config(section, parent=self) primary_candidates.append(entry) self.primary_candidates = primary_candidates + if "fallback" in data: + self.fallback = MirrorSelectionFallback(data.pop("fallback")) merge_config(self.config, data) def _get_apt_config_common(self) -> Dict[str, Any]: assert "disable_components" not in self.config assert "primary" not in self.config + assert "fallback" not in self.config config = copy.deepcopy(self.config) config["disable_components"] = sorted(self.disabled_components) @@ -415,5 +423,6 @@ class MirrorModel(object): else: primary = [c.serialize_for_ai() for c in self.primary_candidates] config["mirror-selection"] = {"primary": primary} + config["fallback"] = self.fallback.value return config diff --git a/subiquity/models/tests/test_mirror.py b/subiquity/models/tests/test_mirror.py index 6776ba72..c4adacdb 100644 --- a/subiquity/models/tests/test_mirror.py +++ b/subiquity/models/tests/test_mirror.py @@ -21,6 +21,7 @@ from subiquity.models.mirror import ( countrify_uri, LEGACY_DEFAULT_PRIMARY_SECTION, MirrorModel, + MirrorSelectionFallback, LegacyPrimaryEntry, PrimaryEntry, ) @@ -177,7 +178,10 @@ class TestMirrorModel(unittest.TestCase): def test_from_autoinstall_no_primary(self): # autoinstall loads to the config directly model = MirrorModel() - data = {'disable_components': ['non-free']} + data = { + 'disable_components': ['non-free'], + 'fallback': 'offline-install', + } model.load_autoinstall_data(data) self.assertFalse(model.legacy_primary) model.primary_candidates[0].stage() @@ -236,6 +240,7 @@ class TestMirrorModel(unittest.TestCase): ] self.model.disabled_components = set(["non-free"]) self.model.legacy_primary = False + self.model.fallback = MirrorSelectionFallback.OFFLINE_INSTALL self.model.primary_candidates = [ PrimaryEntry(uri=None, arches=None, country_mirror=True, parent=self.model), @@ -246,17 +251,20 @@ class TestMirrorModel(unittest.TestCase): ] cfg = self.model.make_autoinstall() self.assertEqual(cfg["disable_components"], ["non-free"]) + self.assertEqual(cfg["fallback"], "offline-install") self.assertEqual(cfg["mirror-selection"]["primary"], expected_primary) def test_make_autoinstall_legacy_primary(self): primary = [{"arches": "amd64", "uri": "http://mirror"}] self.model.disabled_components = set(["non-free"]) + self.model.fallback = MirrorSelectionFallback.ABORT self.model.legacy_primary = True self.model.primary_candidates = \ [LegacyPrimaryEntry(primary, parent=self.model)] self.model.primary_candidates[0].elect() cfg = self.model.make_autoinstall() self.assertEqual(cfg["disable_components"], ["non-free"]) + self.assertEqual(cfg["fallback"], "abort") self.assertEqual(cfg["primary"], primary) def test_create_primary_candidate(self): diff --git a/subiquity/server/controllers/mirror.py b/subiquity/server/controllers/mirror.py index 2ffb6368..1fe56a49 100644 --- a/subiquity/server/controllers/mirror.py +++ b/subiquity/server/controllers/mirror.py @@ -30,7 +30,9 @@ from subiquity.common.types import ( MirrorGet, MirrorPost, MirrorPostResponse, + MirrorSelectionFallback, ) +from subiquity.models.mirror import filter_candidates from subiquity.server.apt import get_apt_configurer, AptConfigCheckError from subiquity.server.controller import SubiquityController from subiquity.server.types import InstallerChannels @@ -122,7 +124,11 @@ class MirrorController(SubiquityController): "pin-priority", ], } - } + }, + "fallback": { + "type": "string", + "enum": [fb.value for fb in MirrorSelectionFallback], + }, } } model_name = "mirror" @@ -220,9 +226,54 @@ class MirrorController(SubiquityController): candidate.elect() + async def apply_fallback(self): + fallback = self.model.fallback + + if fallback == MirrorSelectionFallback.ABORT: + log.error("aborting the install since no primary mirror is" + " usable") + # TODO there is no guarantee that raising this exception will + # actually abort the install. If this is raised from a request + # handler, for instance, it will just return a HTTP 500 error. For + # now, this is acceptable since we do not call apply_fallback from + # request handlers. + raise RuntimeError("aborting install since no mirror is usable") + elif fallback == MirrorSelectionFallback.OFFLINE_INSTALL: + log.warning("reverting to an offline install since no primary" + " mirror is usable") + self.app.base_model.network.force_offline = True + elif fallback == MirrorSelectionFallback.CONTINUE_ANYWAY: + log.warning("continuing the install despite no usable mirror") + # Pick a candidate that is supposedly compatible and that has a + # URI. If it does not work, well that's too bad. + filters = [ + lambda c: c.uri is not None, + lambda c: c.supports_arch(self.model.architecture), + ] + try: + candidate = next(filter_candidates( + self.model.primary_candidates, filters=filters)) + except StopIteration: + candidate = next(filter_candidates( + self.model.get_default_primary_candidates(), + filters=filters)) + log.warning("deciding to elect primary mirror %s", + candidate.serialize_for_ai()) + candidate.elect() + else: + raise RuntimeError(f"invalid fallback value: {fallback}") + + async def run_mirror_selection_or_fallback(self, context): + """ Perform the mirror selection and apply the configured fallback + method if no mirror is usable. """ + try: + await self.find_and_elect_candidate_mirror(context=context) + except NoUsableMirrorError: + await self.apply_fallback() + @with_context() async def apply_autoinstall_config(self, context): - await self.find_and_elect_candidate_mirror(context) + await self.run_mirror_selection_or_fallback(context) def on_geoip(self): if self.geoip_enabled: @@ -267,7 +318,7 @@ class MirrorController(SubiquityController): # marked configured using a POST to mark_configured ; which is not # recommended. Clients should do a POST request to /mirror with # null as the body instead. - await self.find_and_elect_candidate_mirror(self.context) + await self.run_mirror_selection_or_fallback(self.context) await self.apt_configurer.apply_apt_config(self.context, final=True) async def run_mirror_testing(self, output: io.StringIO) -> None: @@ -338,7 +389,7 @@ class MirrorController(SubiquityController): ensure_elected_in_candidates() await self.configured() - return MirrorPostResponse.OK + return MirrorPostResponse.OK async def disable_components_GET(self) -> List[str]: return sorted(self.model.disabled_components) @@ -383,3 +434,9 @@ class MirrorController(SubiquityController): raise MirrorCheckNotStartedError self.mirror_check.task.cancel() self.mirror_check = None + + async def fallback_GET(self) -> MirrorSelectionFallback: + return self.model.fallback + + async def fallback_POST(self, data: MirrorSelectionFallback): + self.model.fallback = data diff --git a/subiquity/server/controllers/tests/test_mirror.py b/subiquity/server/controllers/tests/test_mirror.py index 1fff2d4f..42d5a894 100644 --- a/subiquity/server/controllers/tests/test_mirror.py +++ b/subiquity/server/controllers/tests/test_mirror.py @@ -20,6 +20,7 @@ import unittest from unittest import mock from subiquitycore.tests.mocks import make_app +from subiquity.common.types import MirrorSelectionFallback from subiquity.models.mirror import MirrorModel from subiquity.server.apt import AptConfigCheckError from subiquity.server.controllers.mirror import MirrorController @@ -59,6 +60,7 @@ class TestMirrorController(unittest.IsolatedAsyncioTestCase): self.assertIn("disable_components", config.keys()) self.assertIn("mirror-selection", config.keys()) self.assertIn("geoip", config.keys()) + self.assertIn("fallback", config.keys()) self.assertNotIn("primary", config.keys()) def test_make_autoinstall_legacy(self): @@ -71,6 +73,7 @@ class TestMirrorController(unittest.IsolatedAsyncioTestCase): self.assertIn("disable_components", config.keys()) self.assertIn("primary", config.keys()) self.assertIn("geoip", config.keys()) + self.assertIn("fallback", config.keys()) self.assertNotIn("mirror-selection", config.keys()) async def test_run_mirror_testing(self): @@ -209,3 +212,33 @@ class TestMirrorController(unittest.IsolatedAsyncioTestCase): await self.controller.find_and_elect_candidate_mirror( self.controller.app.context) self.assertIsNone(self.controller.model.primary_elected) + + async def test_apply_fallback(self): + model = self.controller.model = MirrorModel() + app = self.controller.app + + model.fallback = MirrorSelectionFallback.ABORT + with self.assertRaises(RuntimeError): + await self.controller.apply_fallback() + + model.fallback = MirrorSelectionFallback.OFFLINE_INSTALL + app.base_model.network.force_offline = False + await self.controller.apply_fallback() + self.assertTrue(app.base_model.network.force_offline) + + model.fallback = MirrorSelectionFallback.CONTINUE_ANYWAY + app.base_model.network.force_offline = False + await self.controller.apply_fallback() + self.assertFalse(app.base_model.network.force_offline) + + async def test_run_mirror_selection_or_fallback(self): + controller = self.controller + + with mock.patch.object(controller, "apply_fallback") as mock_fallback: + with mock.patch.object(controller, + "find_and_elect_candidate_mirror", + side_effect=[None, NoUsableMirrorError]): + await controller.run_mirror_selection_or_fallback(context=None) + mock_fallback.assert_not_called() + await controller.run_mirror_selection_or_fallback(context=None) + mock_fallback.assert_called_once()