Merge pull request #1030 from mwhudson/source-selection-3

install source selection
This commit is contained in:
Michael Hudson-Doyle 2021-09-14 15:10:58 +12:00 committed by GitHub
commit 1519c49d8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 532 additions and 23 deletions

View File

@ -118,11 +118,13 @@ subiquity/models/mirror.py
subiquity/models/network.py
subiquity/models/proxy.py
subiquity/models/snaplist.py
subiquity/models/source.py
subiquity/models/ssh.py
subiquity/models/subiquity.py
subiquity/models/tests/__init__.py
subiquity/models/tests/test_filesystem.py
subiquity/models/tests/test_mirror.py
subiquity/models/tests/test_source.py
subiquity/models/tests/test_subiquity.py
subiquity/models/timezone.py
subiquity/models/updates.py
@ -144,8 +146,11 @@ subiquity/server/controllers/refresh.py
subiquity/server/controllers/reporting.py
subiquity/server/controllers/shutdown.py
subiquity/server/controllers/snaplist.py
subiquity/server/controllers/source.py
subiquity/server/controllers/ssh.py
subiquity/server/controllers/tests/__init__.py
subiquity/server/controllers/tests/test_keyboard.py
subiquity/server/controllers/tests/test_source.py
subiquity/server/controllers/timezone.py
subiquity/server/controllers/updates.py
subiquity/server/controllers/userdata.py

View File

@ -83,7 +83,7 @@ class SubiquityClient(TuiApplication):
snapd_socket_path = '/run/snapd.socket'
variant = "server"
variant = None
cmdline = ['snap', 'run', 'subiquity']
dryrun_cmdline_module = 'subiquity.cmd.tui'
@ -101,6 +101,7 @@ class SubiquityClient(TuiApplication):
"Welcome",
"Refresh",
"Keyboard",
"Source",
"Zdev",
"Network",
"Proxy",
@ -443,7 +444,8 @@ class SubiquityClient(TuiApplication):
endpoint_names.append(c.endpoint_name)
if endpoint_names:
await self.client.meta.mark_configured.POST(endpoint_names)
await self.client.meta.client_variant.POST(self.variant)
if self.variant:
await self.client.meta.client_variant.POST(self.variant)
self.controllers.index = index - 1
self.next_screen()

View File

@ -24,6 +24,7 @@ from .proxy import ProxyController
from .refresh import RefreshController
from .serial import SerialController
from .snaplist import SnapListController
from .source import SourceController
from .ssh import SSHController
from .welcome import WelcomeController
from .zdev import ZdevController
@ -41,6 +42,7 @@ __all__ = [
'RepeatedController',
'SerialController',
'SnapListController',
'SourceController',
'SSHController',
'WelcomeController',
'ZdevController',

View File

@ -42,10 +42,6 @@ BIOS_GRUB_SIZE_BYTES = 1 * 1024 * 1024 # 1MiB
PREP_GRUB_SIZE_BYTES = 8 * 1024 * 1024 # 8MiB
UEFI_GRUB_SIZE_BYTES = 512 * 1024 * 1024 # 512MiB EFI partition
# Disks larger than this are considered sensible targets for guided
# installation.
MIN_SIZE_GUIDED = 6 * (1 << 30)
class FilesystemController(SubiquityTuiController, FilesystemManipulator):
@ -59,7 +55,7 @@ class FilesystemController(SubiquityTuiController, FilesystemManipulator):
self.answers.setdefault('manual', [])
async def make_ui(self):
status = await self.endpoint.guided.GET(MIN_SIZE_GUIDED)
status = await self.endpoint.guided.GET()
if status.status == ProbeStatus.PROBING:
self.app.aio_loop.create_task(self._wait_for_probing())
return SlowProbing(self)
@ -67,7 +63,7 @@ class FilesystemController(SubiquityTuiController, FilesystemManipulator):
return self.make_guided_ui(status)
async def _wait_for_probing(self):
status = await self.endpoint.guided.GET(MIN_SIZE_GUIDED, wait=True)
status = await self.endpoint.guided.GET(wait=True)
if isinstance(self.ui.body, SlowProbing):
self.ui.set_body(self.make_guided_ui(status))

View File

@ -0,0 +1,42 @@
# Copyright 2021 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 <http://www.gnu.org/licenses/>.
import logging
from subiquity.client.controller import SubiquityTuiController
from subiquity.ui.views.source import SourceView
log = logging.getLogger('subiquity.client.controllers.source')
class SourceController(SubiquityTuiController):
endpoint_name = 'source'
async def make_ui(self):
sources = await self.endpoint.GET()
return SourceView(self, sources.sources, sources.current_id)
def run_answers(self):
if 'source' in self.answers:
self.app.ui.body.form.source.value = self.answers['source']
self.app.ui.body.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))

View File

@ -65,6 +65,8 @@ def make_server_args_parser():
'--snap-section', action='store', default='server',
help=("Show snaps from this section of the store in the snap "
"list screen."))
parser.add_argument(
'--source-catalog', dest='source_catalog', action='store')
return parser

View File

@ -41,6 +41,7 @@ from subiquity.common.types import (
SnapInfo,
SnapListResponse,
SnapSelection,
SourceSelectionAndSetting,
SSHData,
LiveSessionSSHInfo,
StorageResponse,
@ -123,6 +124,10 @@ class API:
class steps:
def GET(index: Optional[str]) -> AnyStep: ...
class source:
def GET() -> SourceSelectionAndSetting: ...
def POST(source_id: str) -> None: ...
class zdev:
def GET() -> List[ZdevInfo]: ...
@ -208,8 +213,7 @@ class API:
class storage:
class guided:
def GET(min_size: int = None, wait: bool = False) \
-> GuidedStorageResponse:
def GET(wait: bool = False) -> GuidedStorageResponse:
pass
def POST(choice: Optional[GuidedChoice]) \

View File

@ -168,6 +168,22 @@ class KeyboardSetup:
layouts: List[KeyboardLayout]
@attr.s(auto_attribs=True)
class SourceSelection:
name: str
description: str
id: str
size: int
variant: str
default: bool
@attr.s(auto_attribs=True)
class SourceSelectionAndSetting:
sources: List[SourceSelection]
current_id: str
@attr.s(auto_attribs=True)
class ZdevInfo:
id: str

View File

@ -0,0 +1,92 @@
# Copyright 2021 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 <http://www.gnu.org/licenses/>.
import logging
import os
import typing
import yaml
import attr
log = logging.getLogger('subiquity.models.source')
@attr.s(auto_attribs=True)
class CatalogEntry:
variant: str
id: str
name: typing.Dict[str, str]
description: typing.Dict[str, str]
path: str
size: int
type: str
default: bool = False
fake_entries = {
'server': CatalogEntry(
variant='server',
id='synthesized',
name={'en': 'Ubuntu Server'},
description={'en': 'the default'},
path='/media/filesystem',
type='cp',
default=True,
size=2 << 30),
'desktop': CatalogEntry(
variant='desktop',
id='synthesized',
name={'en': 'Ubuntu Desktop'},
description={'en': 'the default'},
path='/media/filesystem',
type='cp',
default=True,
size=5 << 30),
}
class SourceModel:
def __init__(self):
self._dir = '/cdrom/casper'
self.current = fake_entries['server']
self.sources = [self.current]
def load_from_file(self, fp):
self._dir = os.path.dirname(fp.name)
self.sources = []
self.current = None
entries = yaml.safe_load(fp)
for entry in entries:
kw = {}
for field in attr.fields(CatalogEntry):
if field.name in entry:
kw[field.name] = entry[field.name]
c = CatalogEntry(**kw)
self.sources.append(c)
if c.default:
self.current = c
log.debug("loaded %d sources from %r", len(self.sources), fp.name)
if self.current is None:
self.current = self.sources[0]
def render(self):
path = os.path.join(self._dir, self.current.path)
scheme = self.current.type
return {
'sources': {
'ubuntu00': f'{scheme}://{path}'
},
}

View File

@ -39,6 +39,7 @@ from .mirror import MirrorModel
from .network import NetworkModel
from .proxy import ProxyModel
from .snaplist import SnapListModel
from .source import SourceModel
from .ssh import SSHModel
from .timezone import TimeZoneModel
from .updates import UpdatesModel
@ -116,6 +117,7 @@ class SubiquityModel:
self.proxy = ProxyModel()
self.snaplist = SnapListModel()
self.ssh = SSHModel()
self.source = SourceModel()
self.timezone = TimeZoneModel()
self.updates = UpdatesModel()
self.userdata = {}
@ -307,16 +309,13 @@ class SubiquityModel:
config = {
'stages': stages,
'sources': {
'ubuntu00': 'cp:///media/filesystem'
},
'curthooks_commands': {
'001-configure-apt': [
resource_path('bin/subiquity-configure-apt'),
sys.executable, str(self.network.has_network).lower(),
],
},
'grub': {
'terminal': 'unmodified',
'probe_additional_os': True

View File

@ -0,0 +1,117 @@
# Copyright 2021 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 <http://www.gnu.org/licenses/>.
import os
import random
import shutil
import tempfile
import unittest
import yaml
from subiquitycore.tests.util import random_string
from subiquity.models.source import (
SourceModel,
)
def make_entry(**fields):
raw = {
'name': {
'en': random_string(),
},
'description': {
'en': random_string(),
},
'id': random_string(),
'type': random_string(),
'variant': random_string(),
'path': random_string(),
'size': random.randint(1000000, 2000000),
}
for k, v in fields.items():
raw[k] = v
return raw
class TestMirrorModel(unittest.TestCase):
def tdir(self):
tdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, tdir)
return tdir
def write_and_load_entries(self, model, entries, dir=None):
if dir is None:
dir = self.tdir()
cat_path = os.path.join(dir, 'catalog.yaml')
with open(cat_path, 'w') as fp:
yaml.dump(entries, fp)
with open(cat_path) as fp:
model.load_from_file(fp)
def test_initially_server(self):
model = SourceModel()
self.assertEqual(model.current.variant, 'server')
def test_load_from_file_simple(self):
entry = make_entry(id='id1')
model = SourceModel()
self.write_and_load_entries(model, [entry])
self.assertEqual(model.current.id, 'id1')
def test_load_from_file_ignores_extra_keys(self):
entry = make_entry(id='id1', foobarbaz=random_string())
model = SourceModel()
self.write_and_load_entries(model, [entry])
self.assertEqual(model.current.id, 'id1')
def test_default(self):
entries = [
make_entry(id='id1'),
make_entry(id='id2', default=True),
make_entry(id='id3'),
]
model = SourceModel()
self.write_and_load_entries(model, entries)
self.assertEqual(model.current.id, 'id2')
def test_render_absolute(self):
entry = make_entry(
type='scheme',
path='/foo/bar/baz',
)
model = SourceModel()
self.write_and_load_entries(model, [entry])
self.assertEqual(
model.render(), {'sources': {'ubuntu00': 'scheme:///foo/bar/baz'}})
def test_render_relative(self):
dir = self.tdir()
entry = make_entry(
type='scheme',
path='foo/bar/baz',
)
model = SourceModel()
self.write_and_load_entries(model, [entry], dir)
self.assertEqual(
model.render(),
{'sources': {'ubuntu00': f'scheme://{dir}/foo/bar/baz'}})
def test_render_initial(self):
model = SourceModel()
self.assertEqual(
model.render(),
{'sources': {'ubuntu00': 'cp:///media/filesystem'}})

View File

@ -29,6 +29,7 @@ from .refresh import RefreshController
from .reporting import ReportingController
from .shutdown import ShutdownController
from .snaplist import SnapListController
from .source import SourceController
from .ssh import SSHController
from .timezone import TimeZoneController
from .updates import UpdatesController
@ -54,6 +55,7 @@ __all__ = [
'ReportingController',
'ShutdownController',
'SnapListController',
'SourceController',
'SSHController',
'TimeZoneController',
'UpdatesController',

View File

@ -62,10 +62,6 @@ from subiquity.server.controller import (
log = logging.getLogger("subiquity.server.controller.filesystem")
block_discover_log = logging.getLogger('block-discover')
# Disks larger than this are considered sensible targets for guided
# installation.
DEFAULT_MIN_SIZE_GUIDED = 6 * (1 << 30)
class FilesystemController(SubiquityController, FilesystemManipulator):
@ -217,13 +213,15 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
config, self.model._probe_data['blockdev'], is_probe_data=False)
self.configured()
async def guided_GET(self, min_size: int = None, wait: bool = False) \
-> GuidedStorageResponse:
async def guided_GET(self, wait: bool = False) -> GuidedStorageResponse:
probe_resp = await self._probe_response(wait, GuidedStorageResponse)
if probe_resp is not None:
return probe_resp
if not min_size:
min_size = DEFAULT_MIN_SIZE_GUIDED
# This calculation is pretty much a hack and we should
# actually think about it at some point (like: maybe the
# source catalog should directly specify the minimum suitable
# size?)
min_size = 2*self.app.base_model.source.current.size + (1 << 30)
disks = []
for raid in self.model._all(type='raid'):
if not boot.can_be_boot_device(raid, with_reformatting=True):

View File

@ -0,0 +1,83 @@
# Copyright 2021 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 <http://www.gnu.org/licenses/>.
import os
from subiquity.common.apidef import API
from subiquity.common.types import (
SourceSelection,
SourceSelectionAndSetting,
)
from subiquity.server.controller import SubiquityController
def _translate(d, lang):
if lang:
for lang in lang, lang.split('_', 1)[0]:
if lang in d:
return d[lang]
return _(d['en'])
def convert_source(source, lang):
return SourceSelection(
name=_translate(source.name, lang),
description=_translate(source.description, lang),
id=source.id,
size=source.size,
variant=source.variant,
default=source.default)
class SourceController(SubiquityController):
model_name = "source"
endpoint = API.source
def start(self):
path = '/cdrom/casper/install-sources.yaml'
if self.app.opts.source_catalog is not None:
path = self.app.opts.source_catalog
if not os.path.exists(path):
return
with open(path) as fp:
self.model.load_from_file(fp)
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]
return SourceSelectionAndSetting(
[
convert_source(source, cur_lang)
for source in self.model.sources
],
self.model.current.id)
def configured(self):
super().configured()
self.app.base_model.set_source_variant(self.model.current.variant)
async def POST(self, source_id: str) -> None:
for source in self.model.sources:
if source.id == source_id:
self.model.current = source
self.configured()

View File

@ -0,0 +1 @@
#

View File

@ -0,0 +1,65 @@
# Copyright 2021 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 <http://www.gnu.org/licenses/>.
import random
import unittest
import attr
from subiquitycore.tests.util import random_string
from subiquity.models.source import CatalogEntry
from subiquity.server.controllers.source import convert_source
def make_entry(**kw):
fields = {
'default': False,
'name': {'en': random_string()},
'description': {'en': random_string()},
'size': random.randint(1000000, 2000000),
}
for field in attr.fields(CatalogEntry):
if field.name not in fields:
fields[field.name] = random_string()
for k, v in kw.items():
fields[k] = v
return CatalogEntry(**fields)
class TestSubiquityModel(unittest.TestCase):
def test_convert_source(self):
entry = make_entry()
source = convert_source(entry, 'C')
self.assertEqual(source.id, entry.id)
def test_convert_translations(self):
entry = make_entry(
name={
'en': 'English',
'fr': 'French',
'fr_CA': 'French Canadian',
})
self.assertEqual(
convert_source(entry, 'C').name, "English")
self.assertEqual(
convert_source(entry, 'en').name, "English")
self.assertEqual(
convert_source(entry, 'fr').name, "French")
self.assertEqual(
convert_source(entry, 'fr_CA').name, "French Canadian")
self.assertEqual(
convert_source(entry, 'fr_BE').name, "French")

View File

@ -116,7 +116,13 @@ class MetaController:
async def client_variant_POST(self, variant: str) -> None:
if variant not in self.app.supported_variants:
raise ValueError(f'unrecognized client variant {variant}')
self.app.base_model.set_source_variant(variant)
from subiquity.models.source import fake_entries
if variant in fake_entries:
if self.app.base_model.source.current.variant != variant:
self.app.base_model.source.current = fake_entries[variant]
self.app.controllers.Source.configured()
else:
self.app.base_model.set_source_variant(variant)
async def ssh_info_GET(self) -> Optional[LiveSessionSSHInfo]:
ips = []
@ -170,6 +176,7 @@ INSTALL_MODEL_NAMES = ModelNames({
"mirror",
"network",
"proxy",
"source",
})
POSTINSTALL_MODEL_NAMES = ModelNames({
@ -214,6 +221,7 @@ class SubiquityServer(Application):
"Kernel",
"Keyboard",
"Zdev",
"Source",
"Network",
"Proxy",
"Mirror",

View File

@ -0,0 +1,69 @@
# Copyright 2021 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 <http://www.gnu.org/licenses/>.
import logging
from urwid import connect_signal
from subiquitycore.view import BaseView
from subiquitycore.ui.form import (
Form,
RadioButtonField,
)
log = logging.getLogger('subiquity.ui.views.source')
class SourceView(BaseView):
title = _("Choose type of install")
def __init__(self, controller, sources, current_id):
self.controller = controller
group = []
ns = {
'cancel_label': _("Back"),
}
initial = {}
for default in True, False:
for source in sorted(sources, key=lambda s: s.id):
if source.default != default:
continue
ns[source.id] = RadioButtonField(
group, source.name, '\n' + source.description)
initial[source.id] = source.id == current_id
SourceForm = type(Form)('SourceForm', (Form,), ns)
log.debug('%r %r', ns, current_id)
self.form = SourceForm(initial=initial)
connect_signal(self.form, 'submit', self.done)
connect_signal(self.form, 'cancel', self.cancel)
excerpt = _("Choose the base for the installation.")
super().__init__(self.form.as_screen(excerpt=excerpt))
def done(self, result):
log.debug("User input: {}".format(result.as_data()))
for k, v in result.as_data().items():
if v:
self.controller.done(k)
def cancel(self, result=None):
self.controller.cancel()

View File

@ -14,7 +14,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import random
import string
def run_coro(coro):
return asyncio.get_event_loop().run_until_complete(coro)
def random_string():
return ''.join(random.choice(string.ascii_letters) for _ in range(8))