server side implementation of source selection
This commit is contained in:
parent
221f7f98f0
commit
a6270cbaa1
|
@ -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
|
||||
|
|
|
@ -61,6 +61,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
|
||||
|
||||
|
||||
|
|
|
@ -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]: ...
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}'
|
||||
},
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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'}})
|
|
@ -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',
|
||||
|
|
|
@ -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()
|
|
@ -0,0 +1 @@
|
|||
#
|
|
@ -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")
|
|
@ -115,7 +115,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 = []
|
||||
|
@ -169,6 +175,7 @@ INSTALL_MODEL_NAMES = ModelNames({
|
|||
"mirror",
|
||||
"network",
|
||||
"proxy",
|
||||
"source",
|
||||
})
|
||||
|
||||
POSTINSTALL_MODEL_NAMES = ModelNames({
|
||||
|
@ -213,6 +220,7 @@ class SubiquityServer(Application):
|
|||
"Kernel",
|
||||
"Keyboard",
|
||||
"Zdev",
|
||||
"Source",
|
||||
"Network",
|
||||
"Proxy",
|
||||
"Mirror",
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in New Issue