mirror: add support for falling back to an offline install

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
This commit is contained in:
Olivier Gayot 2023-02-13 17:31:41 +01:00
parent 10a2e6ac22
commit 6c3ae3c6dd
9 changed files with 150 additions and 5 deletions

View File

@ -386,6 +386,14 @@
"pin-priority"
]
}
},
"fallback": {
"type": "string",
"enum": [
"abort",
"continue-anyway",
"offline-install"
]
}
}
},

View File

@ -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`

View File

@ -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"
]
}
}
},

View File

@ -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: ...

View File

@ -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 = ""

View File

@ -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

View File

@ -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):

View File

@ -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:
@ -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

View File

@ -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()