diff --git a/autoinstall-schema.json b/autoinstall-schema.json index 98db501f..a6bf8e63 100644 --- a/autoinstall-schema.json +++ b/autoinstall-schema.json @@ -118,6 +118,17 @@ ], "additionalProperties": false }, + "source": { + "type": "object", + "properties": { + "search_drivers": { + "type": "boolean" + } + }, + "required": [ + "search_drivers" + ] + }, "network": { "oneOf": [ { diff --git a/examples/answers-bond.yaml b/examples/answers-bond.yaml index d9431b8a..dd085d4a 100644 --- a/examples/answers-bond.yaml +++ b/examples/answers-bond.yaml @@ -1,3 +1,6 @@ +Source: + source: ubuntu-server-minimal + search_drivers: true Welcome: lang: en_US Refresh: diff --git a/examples/answers-guided-lvm.yaml b/examples/answers-guided-lvm.yaml index cfbff2bf..3ff37944 100644 --- a/examples/answers-guided-lvm.yaml +++ b/examples/answers-guided-lvm.yaml @@ -1,3 +1,6 @@ +Source: + source: ubuntu-server + search_drivers: false Welcome: lang: en_US Refresh: diff --git a/examples/answers-imsm.yaml b/examples/answers-imsm.yaml index 0d0c3aa9..50ba63ca 100644 --- a/examples/answers-imsm.yaml +++ b/examples/answers-imsm.yaml @@ -1,4 +1,6 @@ #machine-config: examples/imsm.json +Source: + source: ubuntu-server Welcome: lang: en_US Refresh: diff --git a/examples/answers-lvm-dmcrypt.yaml b/examples/answers-lvm-dmcrypt.yaml index 5b1137c8..455205fe 100644 --- a/examples/answers-lvm-dmcrypt.yaml +++ b/examples/answers-lvm-dmcrypt.yaml @@ -1,3 +1,5 @@ +Source: + source: ubuntu-server-minimal Welcome: lang: en_US Refresh: diff --git a/examples/answers-lvm.yaml b/examples/answers-lvm.yaml index 858aec3c..601dbe55 100644 --- a/examples/answers-lvm.yaml +++ b/examples/answers-lvm.yaml @@ -1,3 +1,5 @@ +Source: + source: ubuntu-server Welcome: lang: en_US Refresh: diff --git a/examples/answers-preserve.yaml b/examples/answers-preserve.yaml index 48ea9a96..46dbb88a 100644 --- a/examples/answers-preserve.yaml +++ b/examples/answers-preserve.yaml @@ -1,4 +1,6 @@ #machine-config: examples/existing-partitions.json +Source: + source: ubuntu-server Welcome: lang: en_US Refresh: diff --git a/examples/answers-raid-lvm.yaml b/examples/answers-raid-lvm.yaml index bb970879..56ad06fa 100644 --- a/examples/answers-raid-lvm.yaml +++ b/examples/answers-raid-lvm.yaml @@ -1,3 +1,5 @@ +Source: + source: ubuntu-server Welcome: lang: en_US Refresh: diff --git a/examples/answers-raid.yaml b/examples/answers-raid.yaml index f5ab1c2b..ffeafe9a 100644 --- a/examples/answers-raid.yaml +++ b/examples/answers-raid.yaml @@ -1,3 +1,5 @@ +Source: + source: ubuntu-server Welcome: lang: en_US Refresh: diff --git a/examples/answers-serial.yaml b/examples/answers-serial.yaml index a7257dcb..89b967e9 100644 --- a/examples/answers-serial.yaml +++ b/examples/answers-serial.yaml @@ -1,4 +1,6 @@ #serial +Source: + source: ubuntu-server Serial: rich: false Welcome: diff --git a/examples/answers-swap.yaml b/examples/answers-swap.yaml index 6135c1eb..cf75d7bf 100644 --- a/examples/answers-swap.yaml +++ b/examples/answers-swap.yaml @@ -1,3 +1,5 @@ +Source: + source: ubuntu-server Welcome: lang: en_US Refresh: diff --git a/examples/answers.yaml b/examples/answers.yaml index 03ef4cc9..88c76794 100644 --- a/examples/answers.yaml +++ b/examples/answers.yaml @@ -1,3 +1,5 @@ +Source: + source: ubuntu-server Welcome: lang: en_US Refresh: diff --git a/scripts/runtests.sh b/scripts/runtests.sh index 960ca47f..e9389034 100755 --- a/scripts/runtests.sh +++ b/scripts/runtests.sh @@ -161,7 +161,8 @@ for answers in examples/answers*.yaml; do "${opts[@]}" \ --machine-config "$config" \ --bootloader uefi \ - --snaps-from-examples + --snaps-from-examples \ + --source-catalog examples/install-sources.yaml validate grep -q 'finish: subiquity/Install/install/postinstall/run_unattended_upgrades: SUCCESS: downloading and installing security updates' $tmpdir/subiquity-server-debug.log else diff --git a/subiquity/client/controllers/drivers.py b/subiquity/client/controllers/drivers.py index 09d63d75..e5efe49a 100644 --- a/subiquity/client/controllers/drivers.py +++ b/subiquity/client/controllers/drivers.py @@ -32,9 +32,12 @@ class DriversController(SubiquityTuiController): async def make_ui(self) -> DriversView: response: DriversResponse = await self.endpoint.GET() - if not response.drivers and response.drivers is not None: + + if not response.search_drivers: raise Skip - return DriversView(self, response.drivers, response.install) + + return DriversView(self, response.drivers, + response.install, response.local_only) async def _wait_drivers(self) -> List[str]: response: DriversResponse = await self.endpoint.GET(wait=True) diff --git a/subiquity/client/controllers/source.py b/subiquity/client/controllers/source.py index 7c212d10..8d3d17de 100644 --- a/subiquity/client/controllers/source.py +++ b/subiquity/client/controllers/source.py @@ -27,18 +27,27 @@ class SourceController(SubiquityTuiController): async def make_ui(self): sources = await self.endpoint.GET() - return SourceView(self, sources.sources, sources.current_id) + return SourceView(self, + sources.sources, + sources.current_id, + sources.search_drivers) def run_answers(self): + form = self.app.ui.body.form + if "search_drivers" in self.answers: + form.search_drivers.value = self.answers["search_drivers"] if 'source' in self.answers: wanted_id = self.answers['source'] - for bf in self.app.ui.body.form._fields: + for bf in form._fields: + if bf is form.search_drivers: + continue bf.value = bf.field.name == wanted_id - self.app.ui.body.form._click_done(None) + form._click_done(None) def cancel(self): self.app.prev_screen() - def done(self, source_id): - log.debug("SourceController.done source_id=%s", source_id) - self.app.next_screen(self.endpoint.POST(source_id)) + def done(self, source_id, search_drivers: bool): + log.debug("SourceController.done source_id=%s, search_drivers=%s", + source_id, search_drivers) + self.app.next_screen(self.endpoint.POST(source_id, search_drivers)) diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 5e82a6cb..35852fb4 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -145,7 +145,7 @@ class API: class source: def GET() -> SourceSelectionAndSetting: ... - def POST(source_id: str) -> None: ... + def POST(source_id: str, search_drivers: bool) -> None: ... class zdev: def GET() -> List[ZdevInfo]: ... diff --git a/subiquity/common/types.py b/subiquity/common/types.py index ad89f397..bafc7ed9 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -183,6 +183,7 @@ class SourceSelection: class SourceSelectionAndSetting: sources: List[SourceSelection] current_id: str + search_drivers: bool @attr.s(auto_attribs=True) @@ -398,9 +399,13 @@ class DriversResponse: :drivers: tells what third-party drivers will be installed should we decide to do it. It will bet set to None until we figure out what drivers are available. + :local_only: tells if we are looking for drivers only from the ISO. + :search_drivers: enables or disables drivers listing. """ install: bool drivers: Optional[List[str]] + local_only: bool + search_drivers: bool @attr.s(auto_attribs=True) diff --git a/subiquity/models/source.py b/subiquity/models/source.py index 1d766dbc..e1df107c 100644 --- a/subiquity/models/source.py +++ b/subiquity/models/source.py @@ -68,6 +68,7 @@ class SourceModel: self.current = fake_entries['server'] self.sources = [self.current] self.lang = None + self.search_drivers = False def load_from_file(self, fp): self._dir = os.path.dirname(fp.name) diff --git a/subiquity/server/controllers/drivers.py b/subiquity/server/controllers/drivers.py index 96e6285d..ade96456 100644 --- a/subiquity/server/controllers/drivers.py +++ b/subiquity/server/controllers/drivers.py @@ -74,6 +74,14 @@ class DriversController(SubiquityController): async def _list_drivers(self, context): with context.child("wait_apt"): await self._wait_apt.wait() + # The APT_CONFIGURED event (which unblocks _wait_apt.wait) is sent + # after the user confirms the destruction changes. At this point, the + # source is already mounted so the user can't go back all the way to + # the source screen to enable/disable the "search drivers" checkbox. + if not self.app.controllers.Source.model.search_drivers: + self.drivers = [] + await self.configured() + return apt = self.app.controllers.Mirror.apt_configurer async with apt.overlay() as d: try: @@ -90,10 +98,16 @@ class DriversController(SubiquityController): await self.configured() async def GET(self, wait: bool = False) -> DriversResponse: + local_only = not self.app.base_model.network.has_network if wait: await asyncio.shield(self._drivers_task) + + search_drivers = self.app.controllers.Source.model.search_drivers + return DriversResponse(install=self.model.do_install, - drivers=self.drivers) + drivers=self.drivers, + local_only=local_only, + search_drivers=search_drivers) async def POST(self, data: DriversPayload) -> None: self.model.do_install = data.install diff --git a/subiquity/server/controllers/source.py b/subiquity/server/controllers/source.py index 385ed3cf..703cc4a8 100644 --- a/subiquity/server/controllers/source.py +++ b/subiquity/server/controllers/source.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional +from typing import Any, Optional import os from curtin.commands.extract import get_handler_for_source @@ -52,11 +52,41 @@ class SourceController(SubiquityController): endpoint = API.source + autoinstall_key = "source" + autoinstall_schema = { + "type": "object", + "properties": { + "search_drivers": { + "type": "boolean", + }, + }, + "required": ["search_drivers"], + } + # Defaults to true for backward compatibility with existing autoinstall + # configurations. Back then, then users were able to install third-party + # drivers without this field. + autoinstall_default = {"search_drivers": True} + def __init__(self, app): super().__init__(app) self._handler = None self.source_path: Optional[str] = None + def make_autoinstall(self): + return {"search_drivers": self.model.search_drivers} + + def load_autoinstall_data(self, data: Any) -> None: + if data is None: + # For some reason, the schema validator does not reject + # "source: null" despite "type" being "object" + data = self.autoinstall_default + + # search_drivers is marked required so the schema validator should + # reject any missing data. + assert "search_drivers" in data + + self.model.search_drivers = data["search_drivers"] + def start(self): path = '/cdrom/casper/install-sources.yaml' if self.app.opts.source_catalog is not None: @@ -72,11 +102,6 @@ class SourceController(SubiquityController): current = self.app.base_model.locale.selected_language self.model.lang = current.split('_')[0] - def interactive(self): - if len(self.model.sources) <= 1: - return False - return super().interactive() - async def GET(self) -> SourceSelectionAndSetting: cur_lang = self.app.base_model.locale.selected_language cur_lang = cur_lang.rsplit('.', 1)[0] @@ -86,7 +111,8 @@ class SourceController(SubiquityController): convert_source(source, cur_lang) for source in self.model.sources ], - self.model.current.id) + self.model.current.id, + search_drivers=self.model.search_drivers) async def configured(self): if self._handler is not None: @@ -100,7 +126,8 @@ class SourceController(SubiquityController): await super().configured() self.app.base_model.set_source_variant(self.model.current.variant) - async def POST(self, source_id: str) -> None: + async def POST(self, source_id: str, search_drivers: bool) -> None: + self.model.search_drivers = search_drivers for source in self.model.sources: if source.id == source_id: self.model.current = source diff --git a/subiquity/tests/api/test_api.py b/subiquity/tests/api/test_api.py index 8151caa0..e5202343 100755 --- a/subiquity/tests/api/test_api.py +++ b/subiquity/tests/api/test_api.py @@ -219,7 +219,8 @@ class TestFlow(TestAPI): 'toggle': None } await inst.post('/keyboard', keyboard) - await inst.post('/source', source_id='ubuntu-server') + await inst.post('/source', + source_id='ubuntu-server', search_drivers=True) await inst.post('/network') await inst.post('/proxy', '') await inst.post('/mirror', 'http://us.archive.ubuntu.com/ubuntu') @@ -1022,6 +1023,8 @@ class TestCancel(TestAPI): async def test_cancel_drivers(self): with patch.dict(os.environ, {'SUBIQUITY_DEBUG': 'has-drivers'}): async with start_server('examples/simple.json') as inst: + await inst.post('/source', source_id="dummy", + search_drivers=True) # /drivers?wait=true is expected to block until APT is # configured. # Let's make sure we cancel it. diff --git a/subiquity/ui/views/drivers.py b/subiquity/ui/views/drivers.py index fa5ec9fa..a10b707e 100644 --- a/subiquity/ui/views/drivers.py +++ b/subiquity/ui/views/drivers.py @@ -26,10 +26,10 @@ from urwid import ( Text, ) -from subiquitycore.ui.buttons import ok_btn +from subiquitycore.ui.buttons import back_btn, ok_btn from subiquitycore.ui.form import ( Form, - BooleanField, + RadioButtonField, ) from subiquitycore.ui.spinner import Spinner from subiquitycore.ui.utils import screen @@ -44,8 +44,16 @@ class DriversForm(Form): available drivers or not. """ cancel_label = _("Back") + ok_label = _("Continue") - install = BooleanField(_("Install the drivers")) + group: List[RadioButtonField] = [] + + install = RadioButtonField( + group, + _("Install all third-party drivers")) + do_not_install = RadioButtonField( + group, + _("Do not install third-party drivers now")) class DriversViewStatus(Enum): @@ -61,11 +69,22 @@ class DriversView(BaseView): form = None def __init__(self, controller, drivers: Optional[List[str]], - install: bool) -> None: + install: bool, local_only: bool) -> None: self.controller = controller + self.local_only = local_only + + self.search_later = [ + Text(_("Note: Once the installation has finished and you are " + + "connected to a network, you can search again for " + + "third-party drivers using the following command:")), + Text(""), + Text(" $ ubuntu-drivers list --recommended --gpgpu"), + ] if drivers is None: self.make_waiting(install) + elif not drivers: + self.make_no_drivers() else: self.make_main(install, drivers) @@ -74,15 +93,24 @@ class DriversView(BaseView): asynchronously. """ self.spinner = Spinner(self.controller.app.aio_loop, style='dots') self.spinner.start() + + if self.local_only: + looking_for_drivers = _("Not connected to a network. " + + "Looking for applicable third-party " + + "drivers available locally...") + else: + looking_for_drivers = _("Looking for applicable third-party " + + "drivers available locally or online...") + rows = [ - Text(_("Looking for applicable third-party drivers...")), + Text(looking_for_drivers), Text(""), self.spinner, ] - self.cont_btn = ok_btn( - _("Continue"), - on_press=lambda sender: self.done(False)) - self._w = screen(rows, [self.cont_btn]) + self.back_btn = back_btn( + _("Back"), + on_press=lambda sender: self.cancel()) + self._w = screen(rows, [self.back_btn]) asyncio.create_task(self._wait(install)) self.status = DriversViewStatus.WAITING @@ -100,16 +128,33 @@ class DriversView(BaseView): """ Change the view into an information page that shows that no third-party drivers are available for installation. """ - rows = [Text(_("No applicable third-party drivers were found."))] + if self.local_only: + no_drivers_found = _("No applicable third-party drivers are " + + "available locally.") + else: + no_drivers_found = _("No applicable third-party drivers are " + + "available locally or online.") + + rows = [Text(no_drivers_found)] + if self.local_only: + rows.append(Text("")) + rows.extend(self.search_later) + self.cont_btn = ok_btn( _("Continue"), on_press=lambda sender: self.done(False)) - self._w = screen(rows, [self.cont_btn]) + self.back_btn = back_btn( + _("Back"), + on_press=lambda sender: self.cancel()) + self._w = screen(rows, [self.cont_btn, self.back_btn]) self.status = DriversViewStatus.NO_DRIVERS def make_main(self, install: bool, drivers: List[str]) -> None: """ Change the view to display the drivers form. """ - self.form = DriversForm(initial={'install': install}) + self.form = DriversForm(initial={ + "install": bool(install), + "do_not_install": (not install), + }) excerpt = _( "The following third-party drivers were found. " @@ -127,6 +172,10 @@ class DriversView(BaseView): rows.append(Text("")) rows.extend(self.form.as_rows()) + if self.local_only: + rows.append(Text("")) + rows.extend(self.search_later) + self._w = screen(rows, self.form.buttons, excerpt=excerpt) self.status = DriversViewStatus.MAIN diff --git a/subiquity/ui/views/source.py b/subiquity/ui/views/source.py index 634c5d08..1991a336 100644 --- a/subiquity/ui/views/source.py +++ b/subiquity/ui/views/source.py @@ -14,13 +14,21 @@ # along with this program. If not, see . import logging -from urwid import connect_signal +from typing import List + +from urwid import ( + connect_signal, + Text, +) from subiquitycore.view import BaseView +from subiquitycore.ui.container import ListBox from subiquitycore.ui.form import ( + BooleanField, Form, RadioButtonField, ) +from subiquitycore.ui.utils import screen log = logging.getLogger('subiquity.ui.views.source') @@ -29,10 +37,10 @@ class SourceView(BaseView): title = _("Choose type of install") - def __init__(self, controller, sources, current_id): + def __init__(self, controller, sources, current_id, search_drivers: bool): self.controller = controller - group = [] + group: List[RadioButtonField] = [] ns = { 'cancel_label': _("Back"), @@ -47,6 +55,12 @@ class SourceView(BaseView): group, source.name, '\n' + source.description) initial[source.id] = source.id == current_id + ns["search_drivers"] = BooleanField( + _("Search for third-party drivers"), "\n" + + _("This software is subject to license terms included with its " + "documentation. Some is proprietary.")) + initial["search_drivers"] = search_drivers + SourceForm = type(Form)('SourceForm', (Form,), ns) log.debug('%r %r', ns, current_id) @@ -57,13 +71,27 @@ class SourceView(BaseView): excerpt = _("Choose the base for the installation.") - super().__init__(self.form.as_screen(excerpt=excerpt)) + # NOTE Hack to insert the "Additional options" text between two fields + # of the form. + rows = self.form.as_rows() + rows.insert(-2, Text("")) + rows.insert(-2, Text("Additional options")) + + super().__init__( + screen( + ListBox(rows), + self.form.buttons, + excerpt=excerpt, + focus_buttons=True)) def done(self, result): - log.debug("User input: {}".format(result.as_data())) + log.debug("User input: %s", result.as_data()) + search_drivers = result.as_data()["search_drivers"] for k, v in result.as_data().items(): + if k == "search_drivers": + continue if v: - self.controller.done(k) + self.controller.done(k, search_drivers=search_drivers) def cancel(self, result=None): self.controller.cancel()