diff --git a/.gitignore b/.gitignore index 7c8764e0..1f3e3acc 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ venv # Runtime output .subiquity + +# ignore local vscode config +.vscode diff --git a/Makefile b/Makefile index 55db49d3..f82c5670 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,12 @@ PYTHONPATH=$(shell pwd):$(shell pwd)/probert:$(shell pwd)/curtin PROBERTDIR=./probert PROBERT_REPO=https://github.com/canonical/probert DRYRUN?=--dry-run --bootloader uefi --machine-config examples/simple.json +SYSTEM_SETUP_DRYRUN?=--dry-run +RECONFIG?=--reconfigure export PYTHONPATH CWD := $(shell pwd) -CHECK_DIRS := console_conf/ subiquity/ subiquitycore/ +CHECK_DIRS := console_conf/ subiquity/ subiquitycore/ system_setup/ PYTHON := python3 ifneq (,$(MACHINE)) @@ -48,6 +50,18 @@ dryrun-serial ui-view-serial: dryrun-server: $(PYTHON) -m subiquity.cmd.server $(DRYRUN) +dryrun-system-setup: + $(PYTHON) -m system_setup.cmd.tui $(SYSTEM_SETUP_DRYRUN) + +dryrun-system-setup-server: + $(PYTHON) -m system_setup.cmd.server $(SYSTEM_SETUP_DRYRUN) + +dryrun-system-setup-recon: + $(PYTHON) -m system_setup.cmd.tui $(SYSTEM_SETUP_DRYRUN) $(RECONFIG) + +dryrun-system-setup-server-recon: + $(PYTHON) -m system_setup.cmd.server $(SYSTEM_SETUP_DRYRUN) $(RECONFIG) + lint: flake8 flake8: diff --git a/examples/answers-system-setup.yaml b/examples/answers-system-setup.yaml new file mode 100644 index 00000000..4da0b03e --- /dev/null +++ b/examples/answers-system-setup.yaml @@ -0,0 +1,14 @@ +Welcome: + lang: en_US +WSLIdentity: + realname: Ubuntu + username: ubuntu + # ubuntu + password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1' +WSLConfigurationBase: + custom_path: '/custom_mnt_path' + custom_mount_opt: 'opt1 opt2 opt3' + gen_host: false + gen_resolvconf: false +Summary: + reboot: yes \ No newline at end of file diff --git a/scripts/runtests.sh b/scripts/runtests.sh index 96c51728..71f42c8b 100755 --- a/scripts/runtests.sh +++ b/scripts/runtests.sh @@ -5,16 +5,26 @@ testschema=.subiquity/test-autoinstall-schema.json export PYTHONPATH=$PWD:$PWD/probert:$PWD/curtin validate () { - python3 scripts/validate-yaml.py .subiquity/subiquity-curtin-install.conf - if [ ! -e .subiquity/subiquity-client-debug.log ] || [ ! -e .subiquity/subiquity-server-debug.log ]; then - echo "log file not created" - exit 1 + mode="install" + [ $# -gt 0 ] && mode="$1" + + if [ "${mode}" = "install" ]; then + python3 scripts/validate-yaml.py .subiquity/subiquity-curtin-install.conf + if [ ! -e .subiquity/subiquity-client-debug.log ] || [ ! -e .subiquity/subiquity-server-debug.log ]; then + echo "log file not created" + exit 1 + fi + if grep passw0rd .subiquity/subiquity-client-debug.log .subiquity/subiquity-server-debug.log | grep -v "Loaded answers" | grep -v "answers_action"; then + echo "password leaked into log file" + exit 1 + fi + netplan generate --root .subiquity + elif [ "${mode}" = "system_setup" ]; then + # TODO WSL: Compare generated wsl.conf to oracle + echo "system setup validation" + else + echo "W: Unknown validation mode: ${mode}" fi - if grep passw0rd .subiquity/subiquity-client-debug.log .subiquity/subiquity-server-debug.log | grep -v "Loaded answers" | grep -v "answers_action"; then - echo "password leaked into log file" - exit 1 - fi - netplan generate --root .subiquity } clean () { @@ -45,19 +55,24 @@ tty=$(tty) || tty=/dev/console export SUBIQUITY_REPLAY_TIMESCALE=100 for answers in examples/answers*.yaml; do clean - config=$(sed -n 's/^#machine-config: \(.*\)/\1/p' $answers || true) - if [ -z "$config" ]; then - config=examples/simple.json + if echo $answers|grep -vq system-setup; then + config=$(sed -n 's/^#machine-config: \(.*\)/\1/p' $answers || true) + if [ -z "$config" ]; then + config=examples/simple.json + fi + serial=$(sed -n 's/^#serial/x/p' $answers || true) + opts='' + if [ -n "$serial" ]; then + opts='--serial' + fi + # The --foreground is important to avoid subiquity getting SIGTTOU-ed. + timeout --foreground 60 sh -c "LANG=C.UTF-8 python3 -m subiquity.cmd.tui --bootloader uefi --answers $answers --dry-run --snaps-from-examples --machine-config $config $opts" < $tty + validate + grep -q 'finish: subiquity/Install/install/postinstall/run_unattended_upgrades: SUCCESS: downloading and installing security updates' .subiquity/subiquity-server-debug.log + else + timeout --foreground 60 sh -c "LANG=C.UTF-8 python3 -m system_setup.cmd.tui --answers $answers --dry-run " < $tty + validate "system_setup" fi - serial=$(sed -n 's/^#serial/x/p' $answers || true) - opts='' - if [ -n "$serial" ]; then - opts='--serial' - fi - # The --foreground is important to avoid subiquity getting SIGTTOU-ed. - timeout --foreground 60 sh -c "LANG=C.UTF-8 python3 -m subiquity.cmd.tui --bootloader uefi --answers $answers --dry-run --snaps-from-examples --machine-config $config $opts" < $tty - validate - grep -q 'finish: subiquity/Install/install/postinstall/run_unattended_upgrades: SUCCESS: downloading and installing security updates' .subiquity/subiquity-server-debug.log done clean diff --git a/setup.py b/setup.py index d635710b..1fadd085 100644 --- a/setup.py +++ b/setup.py @@ -117,6 +117,8 @@ setup(name='subiquity', 'subiquity-server = subiquity.cmd.server:main', 'subiquity-tui = subiquity.cmd.tui:main', 'console-conf-tui = console_conf.cmd.tui:main', + 'system-setup-server = system_setup.cmd.server:main', + 'system-setup-tui = system_setup.cmd.tui:main', ('console-conf-write-login-details = ' 'console_conf.cmd.write_login_details:main'), ], diff --git a/subiquity/client/client.py b/subiquity/client/client.py index d513ce38..ef3f8111 100644 --- a/subiquity/client/client.py +++ b/subiquity/client/client.py @@ -83,6 +83,10 @@ class SubiquityClient(TuiApplication): snapd_socket_path = '/run/snapd.socket' + variant = "server" + cmdline = ['snap', 'run', 'subiquity'] + dryrun_cmdline_module = 'subiquity.cmd.tui' + from subiquity.client import controllers as controllers_mod project = "subiquity" @@ -171,10 +175,10 @@ class SubiquityClient(TuiApplication): return if self.urwid_loop is not None: self.urwid_loop.stop() - cmdline = ['snap', 'run', 'subiquity'] + cmdline = self.cmdline if self.opts.dry_run: cmdline = [ - sys.executable, '-m', 'subiquity.cmd.tui', + sys.executable, '-m', self.dryrun_cmdline_module, ] + sys.argv[1:] + ['--socket', self.opts.socket] if self.opts.server_pid is not None: cmdline.extend(['--server-pid', self.opts.server_pid]) @@ -317,14 +321,18 @@ class SubiquityClient(TuiApplication): print(line) return await super().start() - journald_listen( - self.aio_loop, - [status.event_syslog_id], - self.controllers.Progress.event) - journald_listen( - self.aio_loop, - [status.log_syslog_id], - self.controllers.Progress.log_line) + # Progress uses systemd to collect and display the installation + # logs. Although some system don't have systemd, so we disable + # the progress page + if hasattr(self.controllers, "Progress"): + journald_listen( + self.aio_loop, + [status.event_syslog_id], + self.controllers.Progress.event) + journald_listen( + self.aio_loop, + [status.log_syslog_id], + self.controllers.Progress.log_line) if not status.cloud_init_ok: self.add_global_overlay(CloudInitFail(self)) self.error_reporter.load_reports() @@ -435,7 +443,7 @@ 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('server') + await self.client.meta.client_variant.POST(self.variant) self.controllers.index = index - 1 self.next_screen() diff --git a/subiquity/common/apidef.py b/subiquity/common/apidef.py index 3bacac94..e8400bc4 100644 --- a/subiquity/common/apidef.py +++ b/subiquity/common/apidef.py @@ -47,6 +47,8 @@ from subiquity.common.types import ( TimeZoneInfo, WLANSupportInstallState, ZdevInfo, + WSLConfigurationBase, + WSLConfigurationAdvanced, ) @@ -59,6 +61,8 @@ class API: proxy = simple_endpoint(str) ssh = simple_endpoint(SSHData) updates = simple_endpoint(str) + wslconfbase = simple_endpoint(WSLConfigurationBase) + wslconfadvanced = simple_endpoint(WSLConfigurationAdvanced) class meta: class status: diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 8461b11d..4000e459 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -335,3 +335,30 @@ class TimeZoneInfo: class ShutdownMode(enum.Enum): REBOOT = enum.auto() POWEROFF = enum.auto() + + +@attr.s(auto_attribs=True) +class WSLConfigurationBase: + custom_path: str = attr.ib(default='/mnt/') + custom_mount_opt: str = '' + gen_host: bool = attr.ib(default=True) + gen_resolvconf: bool = attr.ib(default=True) + + +@attr.s(auto_attribs=True) +class WSLConfigurationAdvanced: + gui_theme: str = attr.ib(default='default') + gui_followwintheme: bool = attr.ib(default=True) + legacy_gui: bool = attr.ib(default=False) + legacy_audio: bool = attr.ib(default=False) + adv_ip_detect: bool = attr.ib(default=False) + wsl_motd_news: bool = attr.ib(default=True) + automount: bool = attr.ib(default=True) + mountfstab: bool = attr.ib(default=True) + # TODO WSL: remove all duplications from WSLConfigurationBase + custom_path: str = attr.ib(default='/mnt/') + custom_mount_opt: str = '' + gen_host: bool = attr.ib(default=True) + gen_resolvconf: bool = attr.ib(default=True) + interop_enabled: bool = attr.ib(default=True) + interop_appendwindowspath: bool = attr.ib(default=True) diff --git a/subiquity/models/subiquity.py b/subiquity/models/subiquity.py index 93c93470..99de443c 100644 --- a/subiquity/models/subiquity.py +++ b/subiquity/models/subiquity.py @@ -146,6 +146,13 @@ class SubiquityModel: self._confirmation_task.cancel() else: self._install_event.set() + unconfigured_postinstall_model_names = \ + self._cur_postinstall_model_names - self._configured_names + if unconfigured_postinstall_model_names: + if self._postinstall_event.is_set(): + self._postinstall_event = asyncio.Event() + else: + self._postinstall_event.set() def configured(self, model_name): self._configured_names.add(model_name) diff --git a/subiquity/server/controllers/timezone.py b/subiquity/server/controllers/timezone.py index b3403003..760e7657 100644 --- a/subiquity/server/controllers/timezone.py +++ b/subiquity/server/controllers/timezone.py @@ -19,12 +19,20 @@ import subprocess from subiquity.common.apidef import API from subiquity.common.types import TimeZoneInfo from subiquity.server.controller import SubiquityController +from shutil import which +import os log = logging.getLogger('subiquity.server.controllers.timezone') +def active_timedatectl(): + return which('timedatectl') and os.path.exists('/run/systemd/system') + + def generate_possible_tzs(): special_keys = ['', 'geoip'] + if not active_timedatectl(): + return special_keys tzcmd = ['timedatectl', 'list-timezones'] list_tz_out = subprocess.check_output(tzcmd, universal_newlines=True) real_tzs = list_tz_out.splitlines() diff --git a/subiquity/server/server.py b/subiquity/server/server.py index c4651e94..e833367d 100644 --- a/subiquity/server/server.py +++ b/subiquity/server/server.py @@ -74,6 +74,7 @@ from subiquitycore.snapd import ( SnapdConnection, ) +NOPROBERARG = "NOPROBER" log = logging.getLogger('subiquity.server.server') @@ -112,14 +113,15 @@ class MetaController: controller.configured() async def client_variant_POST(self, variant: str) -> None: - if variant not in ('desktop', 'server'): + if variant not in self.app.supported_variants: raise ValueError(f'unrecognized client variant {variant}') self.app.base_model.set_source_variant(variant) async def ssh_info_GET(self) -> Optional[LiveSessionSSHInfo]: ips = [] - for dev in self.app.base_model.network.get_all_netdevs(): - ips.extend(map(str, dev.actual_global_ip_addresses)) + if self.app.base_model.network: + for dev in self.app.base_model.network.get_all_netdevs(): + ips.extend(map(str, dev.actual_global_ip_addresses)) if not ips: return None username = self.app.installer_user_name @@ -225,6 +227,8 @@ class SubiquityServer(Application): "Shutdown", ] + supported_variants = ["server", "desktop"] + def make_model(self): root = '/' if self.opts.dry_run: @@ -253,7 +257,10 @@ class SubiquityServer(Application): self.error_reporter = ErrorReporter( self.context.child("ErrorReporter"), self.opts.dry_run, self.root) - self.prober = Prober(opts.machine_config, self.debug_flags) + if opts.machine_config == NOPROBERARG: + self.prober = None + else: + self.prober = Prober(opts.machine_config, self.debug_flags) self.kernel_cmdline = shlex.split(opts.kernel_cmdline) if opts.snaps_from_examples: connection = FakeSnapdConnection( @@ -263,9 +270,13 @@ class SubiquityServer(Application): os.path.dirname(__file__))), "examples", "snaps"), self.scale_factor) - else: + self.snapd = AsyncSnapd(connection) + elif os.path.exists(self.snapd_socket_path): connection = SnapdConnection(self.root, self.snapd_socket_path) - self.snapd = AsyncSnapd(connection) + self.snapd = AsyncSnapd(connection) + else: + log.info("no snapd socket found. Snap support is disabled") + self.snapd = None self.note_data_for_apport("SnapUpdated", str(self.updated)) self.event_listeners = [] self.autoinstall_config = None @@ -567,14 +578,20 @@ class SubiquityServer(Application): await self.apply_autoinstall_config() def _network_change(self): + if not self.snapd: + return self.hub.broadcast('snapd-network-change') async def _proxy_set(self): + if not self.snapd: + return await run_in_thread( self.snapd.connection.configure_proxy, self.base_model.proxy) self.hub.broadcast('snapd-network-change') def restart(self): + if not self.snapd: + return cmdline = ['snap', 'run', 'subiquity.subiquity-server'] if self.opts.dry_run: cmdline = [ diff --git a/system_setup/__init__.py b/system_setup/__init__.py new file mode 100644 index 00000000..eb0a6ef9 --- /dev/null +++ b/system_setup/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2015 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 . + +""" Subiquity """ + +from subiquitycore import i18n +__all__ = [ + 'i18n', +] + +__version__ = "0.0.1" diff --git a/system_setup/__main__.py b/system_setup/__main__.py new file mode 100644 index 00000000..5b826b12 --- /dev/null +++ b/system_setup/__main__.py @@ -0,0 +1,20 @@ +# 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 . + +import sys + +if __name__ == '__main__': + from system_setup.cmd.tui import main + sys.exit(main()) diff --git a/system_setup/client/__init__.py b/system_setup/client/__init__.py new file mode 100644 index 00000000..8290406c --- /dev/null +++ b/system_setup/client/__init__.py @@ -0,0 +1,14 @@ +# 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 . diff --git a/system_setup/client/client.py b/system_setup/client/client.py new file mode 100644 index 00000000..2439f967 --- /dev/null +++ b/system_setup/client/client.py @@ -0,0 +1,56 @@ +# 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 . + +import logging +import sys + +from subiquity.client.client import SubiquityClient + +log = logging.getLogger('system_setup.client.client') + + +class SystemSetupClient(SubiquityClient): + + from system_setup.client import controllers as controllers_mod + + snapd_socket_path = None + + variant = "wsl_setup" + cmdline = sys.argv + dryrun_cmdline_module = 'system_setup.cmd.tui' + + controllers = [ + "Welcome", + "WSLIdentity", + "WSLConfigurationBase", + "Summary", + ] + + def __init__(self, opts): + # TODO WSL: + # 1. remove reconfigure flag + # 2. decide on which UI to show up based on existing user UID >=1000 + # (or default user set in wsl.conf?) + # 3. provide an API for this for the flutter UI to know about it + # 4. Add Configuration Base page before Advanced + # 5. Add language page + # self.variant = "wsl_configuration" + if opts.reconfigure: + self.controllers = [ + "WSLConfigurationBase", + "WSLConfigurationAdvanced", + "Summary", + ] + super().__init__(opts) diff --git a/system_setup/client/controllers/__init__.py b/system_setup/client/controllers/__init__.py new file mode 100644 index 00000000..9720388c --- /dev/null +++ b/system_setup/client/controllers/__init__.py @@ -0,0 +1,31 @@ +# 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 . + + +from .identity import WSLIdentityController +from .wslconfbase import WSLConfigurationBaseController +from .summary import SummaryController +from .wslconfadvanced import WSLConfigurationAdvancedController + +from subiquity.client.controllers import WelcomeController + + +__all__ = [ + 'WelcomeController', + 'WSLIdentityController', + 'WSLConfigurationBaseController', + 'WSLConfigurationAdvancedController', + 'SummaryController', +] diff --git a/system_setup/client/controllers/identity.py b/system_setup/client/controllers/identity.py new file mode 100644 index 00000000..48364b53 --- /dev/null +++ b/system_setup/client/controllers/identity.py @@ -0,0 +1,49 @@ +# Copyright 2015-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 . + +import logging + +from subiquity.client.controller import SubiquityTuiController +from subiquity.common.types import IdentityData +from system_setup.ui.views import WSLIdentityView + +log = logging.getLogger('system_setup.client.controllers.identity') + + +class WSLIdentityController(SubiquityTuiController): + + endpoint_name = 'identity' + + async def make_ui(self): + data = await self.endpoint.GET() + return WSLIdentityView(self, data) + + def run_answers(self): + if all(elem in self.answers for elem in + ['realname', 'username', 'password']): + identity = IdentityData( + realname=self.answers['realname'], + username=self.answers['username'], + crypted_password=self.answers['password']) + self.done(identity) + + def cancel(self): + self.app.prev_screen() + + def done(self, identity_data): + log.debug( + "IdentityController.done next_screen user_spec=%s", + identity_data) + self.app.next_screen(self.endpoint.POST(identity_data)) diff --git a/system_setup/client/controllers/summary.py b/system_setup/client/controllers/summary.py new file mode 100644 index 00000000..7616af60 --- /dev/null +++ b/system_setup/client/controllers/summary.py @@ -0,0 +1,94 @@ +import aiohttp +import asyncio +import logging + +from subiquitycore.context import with_context + +from subiquity.client.controller import SubiquityTuiController +from subiquity.common.types import ( + ApplicationState, + ShutdownMode + ) +from subiquity.ui.views.installprogress import ( + InstallRunning, + ) + +from system_setup.ui.views.summary import SummaryView + + +log = logging.getLogger('ubuntu_wsl_oobe.controllers.summary') + + +class SummaryController(SubiquityTuiController): + + def __init__(self, app): + super().__init__(app) + self.app_state = None + self.crash_report_ref = None + self.summary_view = None + + def start(self): + self.app.aio_loop.create_task(self._wait_status()) + + def cancel(self): + self.app.cancel() + + def run_answers(self): + pass + + def click_reboot(self): + self.app.aio_loop.create_task(self.send_reboot_and_wait()) + + async def send_reboot_and_wait(self): + try: + await self.app.client.shutdown.POST(mode=ShutdownMode.REBOOT) + except aiohttp.ClientError: + pass + self.app.exit() + + @with_context() + async def _wait_status(self, context): + install_running = None + while True: + try: + app_status = await self.app.client.meta.status.GET( + cur=self.app_state) + except aiohttp.ClientError: + await asyncio.sleep(1) + continue + self.app_state = app_status.state + + if self.summary_view: + self.summary_view.update_for_state(self.app_state) + if app_status.error is not None: + if self.crash_report_ref is None: + self.crash_report_ref = app_status.error + if self.summary_view: + self.ui.set_body(self.summary_view) + self.app.show_error_report(self.crash_report_ref) + + if self.app_state == ApplicationState.RUNNING: + if app_status.confirming_tty != self.app.our_tty: + install_running = InstallRunning( + self.app, app_status.confirming_tty) + self.app.add_global_overlay(install_running) + else: + if install_running is not None: + self.app.remove_global_overlay(install_running) + install_running = None + + if self.app_state == ApplicationState.DONE: + if self.answers.get('reboot', False): + self.click_reboot() + + async def make_ui(self): + real_name = "" + identity = getattr(self.app.client, "identity") + if identity is not None: + data = await identity.GET() + real_name = data.realname + self.summary_view = SummaryView(self, real_name) + # We may reach the DONE or ERROR state even before we had a chance + # to show the UI. + self.summary_view.update_for_state(self.app_state) + return self.summary_view diff --git a/system_setup/client/controllers/wslconfadvanced.py b/system_setup/client/controllers/wslconfadvanced.py new file mode 100644 index 00000000..8b0d65cd --- /dev/null +++ b/system_setup/client/controllers/wslconfadvanced.py @@ -0,0 +1,67 @@ +# 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 . + +import logging + +from subiquity.client.controller import SubiquityTuiController +from subiquity.common.types import WSLConfigurationAdvanced +from system_setup.ui.views.wslconfadvanced import WSLConfigurationAdvancedView + +log = logging.getLogger( + 'system_setup.client.controllers.wslconfigurationadvanced') + + +class WSLConfigurationAdvancedController(SubiquityTuiController): + endpoint_name = 'wslconfadvanced' + + async def make_ui(self): + data = await self.endpoint.GET() + return WSLConfigurationAdvancedView(self, data) + + def run_answers(self): + if all(elem in self.answers for elem in + ['custom_path', 'custom_mount_opt', 'gen_host', + 'gen_resolvconf', 'interop_enabled', + 'interop_appendwindowspath', 'gui_theme', + 'gui_followwintheme', 'legacy_gui', + 'legacy_audio', 'adv_ip_detect', + 'wsl_motd_news', 'automount', 'mountfstab']): + reconfiguration = WSLConfigurationAdvanced( + custom_path=self.answers['custom_path'], + custom_mount_opt=self.answers['custom_mount_opt'], + gen_host=self.answers['gen_host'], + gen_resolvconf=self.answers['gen_resolvconf'], + interop_enabled=self.answers['interop_enabled'], + interop_appendwindowspath=self + .answers['interop_appendwindowspath'], + gui_theme=self.answers['gui_theme'], + gui_followwintheme=self.answers['gui_followwintheme'], + legacy_gui=self.answers['legacy_gui'], + legacy_audio=self.answers['legacy_audio'], + adv_ip_detect=self.answers['adv_ip_detect'], + wsl_motd_news=self.answers['wsl_motd_news'], + automount=self.answers['automount'], + mountfstab=self.answers['mountfstab'] + ) + self.done(reconfiguration) + + def done(self, reconf_data): + log.debug( + "WSLConfigurationAdvancedController.done next_screen user_spec=%s", + reconf_data) + self.app.next_screen(self.endpoint.POST(reconf_data)) + + def cancel(self): + self.app.prev_screen() diff --git a/system_setup/client/controllers/wslconfbase.py b/system_setup/client/controllers/wslconfbase.py new file mode 100644 index 00000000..a83efc0d --- /dev/null +++ b/system_setup/client/controllers/wslconfbase.py @@ -0,0 +1,35 @@ +import logging + +from subiquity.client.controller import SubiquityTuiController +from subiquity.common.types import WSLConfigurationBase +from system_setup.ui.views.wslconfbase import WSLConfigurationBaseView + +log = logging.getLogger('system_setup.client.controllers.wslconfigurationbase') + + +class WSLConfigurationBaseController(SubiquityTuiController): + endpoint_name = 'wslconfbase' + + async def make_ui(self): + data = await self.endpoint.GET() + return WSLConfigurationBaseView(self, data) + + def run_answers(self): + if all(elem in self.answers for elem in + ['custom_path', 'custom_mount_opt', + 'gen_host', 'gen_resolvconf']): + configuration = WSLConfigurationBase( + custom_path=self.answers['custom_path'], + custom_mount_opt=self.answers['custom_mount_opt'], + gen_host=self.answers['gen_host'], + gen_resolvconf=self.answers['gen_resolvconf']) + self.done(configuration) + + def done(self, configuration_data): + log.debug( + "WSLConfigurationBaseController.done next_screen user_spec=%s", + configuration_data) + self.app.next_screen(self.endpoint.POST(configuration_data)) + + def cancel(self): + self.app.prev_screen() diff --git a/system_setup/cmd/__init__.py b/system_setup/cmd/__init__.py new file mode 100644 index 00000000..8290406c --- /dev/null +++ b/system_setup/cmd/__init__.py @@ -0,0 +1,14 @@ +# 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 . diff --git a/system_setup/cmd/server.py b/system_setup/cmd/server.py new file mode 100644 index 00000000..0e174b1b --- /dev/null +++ b/system_setup/cmd/server.py @@ -0,0 +1,88 @@ +# Copyright 2020-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 . + +import argparse +import logging +import os +import sys + +from subiquitycore.log import setup_logger + +from subiquity.cmd.common import ( + LOGDIR, + setup_environment, + ) + + +def make_server_args_parser(): + parser = argparse.ArgumentParser( + description='System Setup - Initial Boot Setup', + prog='system_setup') + parser.add_argument('--dry-run', action='store_true', + dest='dry_run', + help='menu-only, do not call installer function') + parser.add_argument('--socket') + parser.add_argument('--autoinstall', action='store') + return parser + + +def main(): + print('starting server') + setup_environment() + # setup_environment sets $APPORT_DATA_DIR which must be set before + # apport is imported, which is done by this import: + from system_setup.server.server import SystemSetupServer + from subiquity.server.server import NOPROBERARG + parser = make_server_args_parser() + opts = parser.parse_args(sys.argv[1:]) + logdir = LOGDIR + opts.snaps_from_examples = False + opts.kernel_cmdline = "" + opts.machine_config = NOPROBERARG + if opts.dry_run: + logdir = ".subiquity" + if opts.socket is None: + if opts.dry_run: + opts.socket = '.subiquity/socket' + else: + opts.socket = '/run/subiquity/socket' + os.makedirs(os.path.dirname(opts.socket), exist_ok=True) + + block_log_dir = os.path.join(logdir, "block") + os.makedirs(block_log_dir, exist_ok=True) + handler = logging.FileHandler(os.path.join(block_log_dir, 'discover.log')) + handler.setLevel('DEBUG') + handler.setFormatter( + logging.Formatter("%(asctime)s %(name)s:%(lineno)d %(message)s")) + + logfiles = setup_logger(dir=logdir, base='systemsetup-server') + + logger = logging.getLogger('systemsetup-server') + version = "unknown" + logger.info("Starting System Setup server revision {}".format(version)) + logger.info("Arguments passed: {}".format(sys.argv)) + + server = SystemSetupServer(opts, block_log_dir) + + server.note_file_for_apport( + "InstallerServerLog", logfiles['debug']) + server.note_file_for_apport( + "InstallerServerLogInfo", logfiles['info']) + + server.run() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/system_setup/cmd/tui.py b/system_setup/cmd/tui.py new file mode 100755 index 00000000..92fb8c57 --- /dev/null +++ b/system_setup/cmd/tui.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# Copyright 2015-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 . + +import argparse +import logging +import os +import fcntl +import subprocess +import sys + +from subiquitycore.log import setup_logger + +from subiquity.cmd.common import ( + LOGDIR, + setup_environment, + ) +from system_setup.cmd.server import make_server_args_parser + + +class ClickAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + namespace.scripts.append("c(" + repr(values) + ")") + + +def make_client_args_parser(): + # TODO WSL: update this. We have already done it on the past… + parser = argparse.ArgumentParser( + description='SUbiquity - Ubiquity for Servers', + prog='subiquity') + try: + ascii_default = os.ttyname(0) == "/dev/ttysclp0" + except OSError: + ascii_default = False + parser.set_defaults(ascii=ascii_default) + parser.add_argument('--dry-run', action='store_true', + dest='dry_run', + help='menu-only, do not call installer function') + # TODO WSL: remove any uneeded arguments + parser.add_argument('--socket') + parser.add_argument('--serial', action='store_true', + dest='run_on_serial', + help='Run the installer over serial console.') + parser.add_argument('--ssh', action='store_true', + dest='ssh', + help='Print ssh login details') + parser.add_argument('--ascii', action='store_true', + dest='ascii', + help='Run the installer in ascii mode.') + parser.add_argument('--unicode', action='store_false', + dest='ascii', + help='Run the installer in unicode mode.') + parser.add_argument('--screens', action='append', dest='screens', + default=[]) + parser.add_argument('--script', metavar="SCRIPT", action='append', + dest='scripts', default=[], + help=('Execute SCRIPT in a namespace containing view ' + 'helpers and "ui"')) + parser.add_argument('--click', metavar="PAT", action=ClickAction, + help='Synthesize a click on a button matching PAT') + parser.add_argument('--answers') + parser.add_argument('--server-pid') + # TODO WSL: remove reconfigure flag and use dynamic decision (see below) + # Expose that as an endpoint on the server and decide in the client what + # to show + parser.add_argument('--reconfigure', action='store_true') + return parser + + +AUTO_ANSWERS_FILE = "/subiquity_config/answers.yaml" + + +def main(): + setup_environment() + # setup_environment sets $APPORT_DATA_DIR which must be set before + # apport is imported, which is done by this import: + from system_setup.client.client import SystemSetupClient + parser = make_client_args_parser() + args = sys.argv[1:] + # TODO: make that a common helper between subiquity and system_setup + if '--dry-run' in args: + opts, unknown = parser.parse_known_args(args) + if opts.socket is None: + os.makedirs('.subiquity', exist_ok=True) + sock_path = '.subiquity/socket' + opts.socket = sock_path + server_args = ['--dry-run', '--socket=' + sock_path] + unknown + server_parser = make_server_args_parser() + server_parser.parse_args(server_args) # just to check + server_output = open('.subiquity/server-output', 'w') + server_cmd = [sys.executable, '-m', 'system_setup.cmd.server'] + \ + server_args + server_proc = subprocess.Popen( + server_cmd, stdout=server_output, stderr=subprocess.STDOUT) + opts.server_pid = str(server_proc.pid) + print("running server pid {}".format(server_proc.pid)) + elif opts.server_pid is not None: + print("reconnecting to server pid {}".format(opts.server_pid)) + else: + opts = parser.parse_args(args) + else: + opts = parser.parse_args(args) + if opts.socket is None: + opts.socket = '/run/subiquity/socket' + os.makedirs(os.path.basename(opts.socket), exist_ok=True) + logdir = LOGDIR + if opts.dry_run: + logdir = ".subiquity" + logfiles = setup_logger(dir=logdir, base='systemsetup-client') + + logger = logging.getLogger('subiquity') + version = "unknown" + logger.info("Starting System Setup revision {}".format(version)) + logger.info("Arguments passed: {}".format(sys.argv)) + + if opts.answers is None and os.path.exists(AUTO_ANSWERS_FILE): + logger.debug("Autoloading answers from %s", AUTO_ANSWERS_FILE) + opts.answers = AUTO_ANSWERS_FILE + + if opts.answers: + opts.answers = open(opts.answers) + try: + fcntl.flock(opts.answers, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + logger.exception( + 'Failed to lock auto answers file, proceding without it.') + opts.answers.close() + opts.answers = None + + subiquity_interface = SystemSetupClient(opts) + + subiquity_interface.note_file_for_apport( + "InstallerLog", logfiles['debug']) + subiquity_interface.note_file_for_apport( + "InstallerLogInfo", logfiles['info']) + + subiquity_interface.run() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/system_setup/models/__init__.py b/system_setup/models/__init__.py new file mode 100644 index 00000000..8290406c --- /dev/null +++ b/system_setup/models/__init__.py @@ -0,0 +1,14 @@ +# 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 . diff --git a/system_setup/models/system_setup.py b/system_setup/models/system_setup.py new file mode 100644 index 00000000..3496b474 --- /dev/null +++ b/system_setup/models/system_setup.py @@ -0,0 +1,70 @@ +# Copyright 2015 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 . + +import asyncio +import logging + +from subiquity.models.subiquity import SubiquityModel + +from subiquity.models.locale import LocaleModel +from subiquity.models.identity import IdentityModel +from .wslconfbase import WSLConfigurationBaseModel +from .wslconfadvanced import WSLConfigurationAdvancedModel + + +log = logging.getLogger('system_setup.models.system_server') + +HOSTS_CONTENT = """\ +127.0.0.1 localhost +127.0.1.1 {hostname} + +# The following lines are desirable for IPv6 capable hosts +::1 ip6-localhost ip6-loopback +fe00::0 ip6-localnet +ff00::0 ip6-mcastprefix +ff02::1 ip6-allnodes +ff02::2 ip6-allrouters +""" + + +class SystemSetupModel(SubiquityModel): + """The overall model for subiquity.""" + + target = '/' + + def __init__(self, root, install_model_names, postinstall_model_names): + # Parent class init is not called to not load models we don't need. + self.root = root + if root != '/': + self.target = root + + self.packages = [] + self.userdata = {} + self.locale = LocaleModel() + self.identity = IdentityModel() + self.wslconfbase = WSLConfigurationBaseModel() + self.wslconfadvanced = WSLConfigurationAdvancedModel() + + self._confirmation = asyncio.Event() + self._confirmation_task = None + + self._configured_names = set() + self._install_model_names = install_model_names + self._postinstall_model_names = postinstall_model_names + self._cur_install_model_names = install_model_names.default_names + self._cur_postinstall_model_names = \ + postinstall_model_names.default_names + self._install_event = asyncio.Event() + self._postinstall_event = asyncio.Event() diff --git a/system_setup/models/wslconfadvanced.py b/system_setup/models/wslconfadvanced.py new file mode 100644 index 00000000..3b6262b2 --- /dev/null +++ b/system_setup/models/wslconfadvanced.py @@ -0,0 +1,135 @@ +# Copyright 2015 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 . + +import logging +import subprocess +import attr + +from subiquitycore.utils import run_command + +log = logging.getLogger('subiquity.models.wsl_configuration_advanced') + +# TODO WSL: Remove all common attributes with wslconfbase + + +@attr.s +class WSLConfigurationAdvanced(object): + gui_theme = attr.ib() + gui_followwintheme = attr.ib() + legacy_gui = attr.ib() + legacy_audio = attr.ib() + adv_ip_detect = attr.ib() + wsl_motd_news = attr.ib() + automount = attr.ib() + mountfstab = attr.ib() + custom_path = attr.ib() + custom_mount_opt = attr.ib() + gen_host = attr.ib() + gen_resolvconf = attr.ib() + interop_enabled = attr.ib() + interop_appendwindowspath = attr.ib() + + +class WSLConfigurationAdvancedModel(object): + """ Model representing integration + """ + + def __init__(self): + self._wslconfadvanced = None + # TODO WSL: Load settings from system + + def apply_settings(self, result, is_dry_run=False): + d = {} + # TODO: placholder settings; should be dynamically assgined using + # ubuntu-wsl-integration + d['custom_path'] = result.custom_path + d['custom_mount_opt'] = result.custom_mount_opt + d['gen_host'] = result.gen_host + d['gen_resolvconf'] = result.gen_resolvconf + d['interop_enabled'] = result.interop_enabled + d['interop_appendwindowspath'] = result.interop_appendwindowspath + d['gui_theme'] = result.gui_theme + d['gui_followwintheme'] = result.gui_followwintheme + d['legacy_gui'] = result.legacy_gui + d['legacy_audio'] = result.legacy_audio + d['adv_ip_detect'] = result.adv_ip_detect + d['wsl_motd_news'] = result.wsl_motd_news + d['automount'] = result.automount + d['mountfstab'] = result.mountfstab + self._wslconfadvanced = WSLConfigurationAdvancedModel(**d) + # TODO WSL: Drop all calls of ubuntuwsl here and ensure the data + # are passed to the app model + if not is_dry_run: + # reset to keep everything as refreshed as new + run_command(["/usr/bin/ubuntuwsl", "reset", "-y"], + stdout=subprocess.DEVNULL) + # set the settings + # TODO: placholder settings; should be dynamically generated using + # ubuntu-wsl-integration + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.automount.enabled", result.automount], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.automount.mountfstab", result.mountfstab], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.automount.root", result.custom_path], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.automount.options", result.custom_mount_opt], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.network.generatehosts", result.gen_host], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.network.generateresolvconf", + result.gen_resolvconf], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.interop.enabled", + result.interop_enabled], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.interop.appendwindowspath", + result.interop_appendwindowspath], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "ubuntu.GUI.followwintheme", + result.gui_followwintheme], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "ubuntu.GUI.theme", result.gui_theme], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "ubuntu.Interop.guiintergration", result.legacy_gui], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "ubuntu.Interop.audiointegration", + result.legacy_audio], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "ubuntu.Interop.advancedipdetection", + result.adv_ip_detect], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "ubuntu.Motd.wslnewsenabled", result.wsl_motd_news], + stdout=subprocess.DEVNULL) + + @property + def wslconfadvanced(self): + return self._wslconfadvanced + + def __repr__(self): + return "".format(self.wslconfadvanced) diff --git a/system_setup/models/wslconfbase.py b/system_setup/models/wslconfbase.py new file mode 100644 index 00000000..c2df10b5 --- /dev/null +++ b/system_setup/models/wslconfbase.py @@ -0,0 +1,74 @@ +# Copyright 2015 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 . + +import logging +import subprocess +import attr + +from subiquitycore.utils import run_command + +log = logging.getLogger('subiquity.models.wsl_configuration_base') + + +@attr.s +class WSLConfigurationBase(object): + custom_path = attr.ib() + custom_mount_opt = attr.ib() + gen_host = attr.ib() + gen_resolvconf = attr.ib() + + +class WSLConfigurationBaseModel(object): + """ Model representing basic wsl configuration + """ + + def __init__(self): + self._wslconfbase = None + # TODO WSL: Load settings from system + + def apply_settings(self, result, is_dry_run=False): + d = {} + d['custom_path'] = result.custom_path + d['custom_mount_opt'] = result.custom_mount_opt + d['gen_host'] = result.gen_host + d['gen_resolvconf'] = result.gen_resolvconf + self._wslconfbase = WSLConfigurationBase(**d) + # TODO WSL: Drop all calls of ubuntuwsl here and ensure the data + # are passed to the app model + if not is_dry_run: + # reset to keep everything as refreshed as new + run_command(["/usr/bin/ubuntuwsl", "reset", "-y"], + stdout=subprocess.DEVNULL) + # set the settings + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.automount.root", result.custom_path], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.automount.options", result.custom_mount_opt], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.network.generatehosts", result.gen_host], + stdout=subprocess.DEVNULL) + run_command(["/usr/bin/ubuntuwsl", "update", + "WSL.network.generateresolvconf", + result.gen_resolvconf], + stdout=subprocess.DEVNULL) + + @property + def wslconfbase(self): + return self._wslconfbase + + def __repr__(self): + return "".format(self.wslconfbase) diff --git a/system_setup/server/__init__.py b/system_setup/server/__init__.py new file mode 100644 index 00000000..8290406c --- /dev/null +++ b/system_setup/server/__init__.py @@ -0,0 +1,14 @@ +# 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 . diff --git a/system_setup/server/controllers/__init__.py b/system_setup/server/controllers/__init__.py new file mode 100644 index 00000000..e75eec73 --- /dev/null +++ b/system_setup/server/controllers/__init__.py @@ -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 . + +from subiquity.server.controllers.cmdlist import ( + EarlyController, + LateController, + ErrorController, + ) +from subiquity.server.controllers.locale import LocaleController +from subiquity.server.controllers.reporting import ReportingController +from subiquity.server.controllers.userdata import UserdataController +from .identity import IdentityController +from .wslconfbase import WSLConfigurationBaseController +from .wslconfadvanced import WSLConfigurationAdvancedController +from .configure import ConfigureController +from .shutdown import SetupShutdownController + +__all__ = [ + 'EarlyController', + 'ErrorController', + 'IdentityController', + 'LateController', + 'LocaleController', + 'ReportingController', + 'SetupShutdownController', + 'UserdataController', + 'WSLConfigurationBaseController', + 'WSLConfigurationAdvancedController', + 'ConfigureController', +] diff --git a/system_setup/server/controllers/configure.py b/system_setup/server/controllers/configure.py new file mode 100644 index 00000000..63957943 --- /dev/null +++ b/system_setup/server/controllers/configure.py @@ -0,0 +1,80 @@ +# Copyright 2020 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 . + +import logging + +from subiquitycore.context import with_context + +from subiquity.common.errorreport import ErrorReportKind +from subiquity.server.controller import ( + SubiquityController, + ) + +from subiquity.common.types import ( + ApplicationState, + ) + +log = logging.getLogger("subiquity.system_setup.controllers.configure") + + +class ConfigureController(SubiquityController): + + def __init__(self, app): + super().__init__(app) + self.model = app.base_model + + def start(self): + self.install_task = self.app.aio_loop.create_task(self.configure()) + + @with_context( + description="final system configuration", level="INFO", + childlevel="DEBUG") + async def configure(self, *, context): + context.set('is-install-context', True) + try: + + self.app.update_state(ApplicationState.WAITING) + + await self.model.wait_install() + + self.app.update_state(ApplicationState.NEEDS_CONFIRMATION) + + self.app.update_state(ApplicationState.RUNNING) + + await self.model.wait_postinstall() + + self.app.update_state(ApplicationState.POST_WAIT) + + # TODO WSL: + # 1. Use self.model to get all data to commit + # 2. Write directly (without wsl utilities) to wsl.conf and other + # fstab files + # 3. If not in reconfigure mode: create User, otherwise just write + # wsl.conf files. + # This must not use subprocesses. + # If dry-run: write in .subiquity + + self.app.update_state(ApplicationState.POST_RUNNING) + + self.app.update_state(ApplicationState.DONE) + except Exception: + kw = {} + self.app.make_apport_report( + ErrorReportKind.INSTALL_FAIL, "configuration failed", **kw) + raise + + def stop_uu(self): + # This is a no-op to allow Shutdown controller to depend on this one + pass diff --git a/system_setup/server/controllers/identity.py b/system_setup/server/controllers/identity.py new file mode 100644 index 00000000..16dd98cc --- /dev/null +++ b/system_setup/server/controllers/identity.py @@ -0,0 +1,60 @@ +# Copyright 2015 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 . + +import logging + +import attr + +from subiquity.common.types import IdentityData +from subiquity.server.controllers.identity import IdentityController + +log = logging.getLogger('system_setup.server.controllers.identity') + + +class WSLIdentityController(IdentityController): + + autoinstall_schema = { + 'type': 'object', + 'properties': { + 'realname': {'type': 'string'}, + 'username': {'type': 'string'}, + 'password': {'type': 'string'}, + }, + 'required': ['username', 'password'], + 'additionalProperties': False, + } + + def load_autoinstall_data(self, data): + if data is not None: + identity_data = IdentityData( + realname=data.get('realname', ''), + username=data['username'], + hostname='', + crypted_password=data['password'], + ) + self.model.add_user(identity_data) + + def make_autoinstall(self): + if self.model.user is None: + return {} + r = attr.asdict(self.model.user) + return r + + async def GET(self) -> IdentityData: + data = IdentityData() + if self.model.user is not None: + data.username = self.model.user.username + data.realname = self.model.user.realname + return data diff --git a/system_setup/server/controllers/shutdown.py b/system_setup/server/controllers/shutdown.py new file mode 100644 index 00000000..17859cb8 --- /dev/null +++ b/system_setup/server/controllers/shutdown.py @@ -0,0 +1,50 @@ +# Copyright 2020-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 . + +import logging + +from subiquitycore.context import with_context +from subiquity.common.types import ShutdownMode +from subiquity.server.controllers import ShutdownController + +log = logging.getLogger("system_setup.controllers.restart") + + +class SetupShutdownController(ShutdownController): + + def __init__(self, app): + # This isn't the most beautiful way, but the shutdown controller + # depends on Install, override with our configure one. + super().__init__(app) + self.app.controllers.Install = self.app.controllers.Configure + + def start(self): + # Do not copy logs to target + self.server_reboot_event.set() + self.app.aio_loop.create_task(self._run()) + + @with_context(description='mode={self.mode.name}') + def shutdown(self, context): + self.shuttingdown_event.set() + if not self.opts.dry_run: + if self.mode == ShutdownMode.REBOOT: + # TODO WSL: + # Implement a reboot that doesn't depend on systemd + log.Warning("reboot command not implemented") + elif self.mode == ShutdownMode.POWEROFF: + # TODO WSL: + # Implement a poweroff that doesn't depend on systemd + log.Warning("poweroff command not implemented") + self.app.exit() diff --git a/system_setup/server/controllers/wslconfadvanced.py b/system_setup/server/controllers/wslconfadvanced.py new file mode 100644 index 00000000..3de13d2a --- /dev/null +++ b/system_setup/server/controllers/wslconfadvanced.py @@ -0,0 +1,190 @@ +# 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 . + +import logging + +import attr +from os import path +import configparser +from subiquitycore.context import with_context + +from subiquity.common.apidef import API +from subiquity.common.types import WSLConfigurationAdvanced +from subiquity.server.controller import SubiquityController + +log = logging.getLogger( + 'subiquity.server.controllers.wsl_configuration_advanced') + + +# TODO WSL: remove all duplicates from WSL config base controller +class WSLConfigurationAdvancedController(SubiquityController): + + endpoint = API.wslconfadvanced + + autoinstall_key = model_name = "wslconfadvanced" + autoinstall_schema = { + 'type': 'object', + 'properties': { + 'custom_path': {'type': 'string'}, + 'custom_mount_opt': {'type': 'string'}, + 'gen_host': {'type': 'boolean'}, + 'gen_resolvconf': {'type': 'boolean'}, + 'interop_enabled': {'type': 'boolean'}, + 'interop_appendwindowspath': {'type': 'boolean'}, + 'gui_theme': {'type': 'string'}, + 'gui_followwintheme': {'type': 'boolean'}, + 'legacy_gui': {'type': 'boolean'}, + 'legacy_audio': {'type': 'boolean'}, + 'adv_ip_detect': {'type': 'boolean'}, + 'wsl_motd_news': {'type': 'boolean'}, + 'automount': {'type': 'boolean'}, + 'mountfstab': {'type': 'boolean'} + }, + 'required': [], + 'additionalProperties': False, + } + + # this is a temporary simplified reference. The future complete reference + # should use the default.json in `ubuntu-wsl-integration`. + config_ref = { + "wsl": { + "automount": { + "enabled": "automount", + "mountfstab": "mountfstab", + "root": "custom_path", + "options": "custom_mount_opt", + }, + "network": { + "generatehosts": "gen_host", + "generateresolvconf": "gen_resolvconf", + }, + "interop": { + "enabled": "interop_enabled", + "appendwindowspath": "interop_appendwindowspath", + } + }, + "ubuntu": { + "GUI": { + "theme": "gui_theme", + "followwintheme": "gui_followwintheme", + }, + "Interop": { + "guiintegration": "legacy_gui", + "audiointegration": "legacy_audio", + "advancedipdetection": "adv_ip_detect", + }, + "Motd": { + "wslnewsenabled": "wsl_motd_news", + } + } + } + + def __init__(self, app): + super().__init__(app) + + # load the config file + data = {} + if path.exists('/etc/wsl.conf'): + wslconfig = configparser.ConfigParser() + wslconfig.read('/etc/wsl.conf') + for a in wslconfig: + if a in self.config_ref['wsl']: + a_x = wslconfig[a] + for b in a_x: + if b in self.config_ref['wsl'][a]: + data[self.config_ref['wsl'][a][b]] = a_x[b] + if path.exists('/etc/ubuntu-wsl.conf'): + ubuntuconfig = configparser.ConfigParser() + ubuntuconfig.read('/etc/ubuntu-wsl.conf') + for a in ubuntuconfig: + if a in self.config_ref['ubuntu']: + a_x = ubuntuconfig[a] + for b in a_x: + if b in self.config_ref['ubuntu'][a]: + data[self.config_ref['ubuntu'][a][b]] = a_x[b] + if data: + def bool_converter(x): + return x == 'true' + reconf_data = WSLConfigurationAdvanced( + custom_path=data['custom_path'], + custom_mount_opt=data['custom_mount_opt'], + gen_host=bool_converter(data['gen_host']), + gen_resolvconf=bool_converter(data['gen_resolvconf']), + interop_enabled=bool_converter(data['interop_enabled']), + interop_appendwindowspath=bool_converter( + data['interop_appendwindowspath']), + gui_theme=data['gui_theme'], + gui_followwintheme=bool_converter(data['gui_followwintheme']), + legacy_gui=bool_converter(data['legacy_gui']), + legacy_audio=bool_converter(data['legacy_audio']), + adv_ip_detect=bool_converter(data['adv_ip_detect']), + wsl_motd_news=bool_converter(data['wsl_motd_news']), + automount=bool_converter(data['automount']), + mountfstab=bool_converter(data['mountfstab']), + ) + self.model.apply_settings(reconf_data, self.opts.dry_run) + + def load_autoinstall_data(self, data): + if data is not None: + reconf_data = WSLConfigurationAdvanced( + custom_path=data['custom_path'], + custom_mount_opt=data['custom_mount_opt'], + gen_host=data['gen_host'], + gen_resolvconf=data['gen_resolvconf'], + interop_enabled=data['interop_enabled'], + interop_appendwindowspath=data['interop_appendwindowspath'], + gui_theme=data['gui_theme'], + gui_followwintheme=data['gui_followwintheme'], + legacy_gui=data['legacy_gui'], + legacy_audio=data['legacy_audio'], + adv_ip_detect=data['adv_ip_detect'], + wsl_motd_news=data['wsl_motd_news'], + automount=data['automount'], + mountfstab=data['mountfstab'] + ) + self.model.apply_settings(reconf_data, self.opts.dry_run) + + @with_context() + async def apply_autoinstall_config(self, context=None): + pass + + def make_autoinstall(self): + r = attr.asdict(self.model.wslconfadvanced) + return r + + async def GET(self) -> WSLConfigurationAdvanced: + data = WSLConfigurationAdvanced() + if self.model.wslconfadvanced is not None: + data.custom_path = self.model.wslconfadvanced.custom_path + data.custom_mount_opt = self.model.wslconfadvanced.custom_mount_opt + data.gen_host = self.model.wslconfadvanced.gen_host + data.gen_resolvconf = self.model.wslconfadvanced.gen_resolvconf + data.interop_enabled = self.model.wslconfadvanced.interop_enabled + data.interop_appendwindowspath = \ + self.model.wslconfadvanced.interop_appendwindowspath + data.gui_theme = self.model.wslconfadvanced.gui_theme + data.gui_followwintheme = \ + self.model.wslconfadvanced.gui_followwintheme + data.legacy_gui = self.model.wslconfadvanced.legacy_gui + data.legacy_audio = self.model.wslconfadvanced.legacy_audio + data.adv_ip_detect = self.model.wslconfadvanced.adv_ip_detect + data.wsl_motd_news = self.model.wslconfadvanced.wsl_motd_news + data.automount = self.model.wslconfadvanced.automount + data.mountfstab = self.model.wslconfadvanced.mountfstab + return data + + async def POST(self, data: WSLConfigurationAdvanced): + self.model.apply_settings(data, self.opts.dry_run) + self.configured() diff --git a/system_setup/server/controllers/wslconfbase.py b/system_setup/server/controllers/wslconfbase.py new file mode 100644 index 00000000..41f90350 --- /dev/null +++ b/system_setup/server/controllers/wslconfbase.py @@ -0,0 +1,75 @@ +# 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 . + +import logging + +import attr + +from subiquitycore.context import with_context + +from subiquity.common.apidef import API +from subiquity.common.types import WSLConfigurationBase +from subiquity.server.controller import SubiquityController + +log = logging.getLogger('subiquity.server.controllers.wsl_configuration_base') + + +class WSLConfigurationBaseController(SubiquityController): + + endpoint = API.wslconfbase + + autoinstall_key = model_name = "wslconfbase" + autoinstall_schema = { + 'type': 'object', + 'properties': { + 'custom_path': {'type': 'string'}, + 'custom_mount_opt': {'type': 'string'}, + 'gen_host': {'type': 'boolean'}, + 'gen_resolvconf': {'type': 'boolean'}, + }, + 'required': [], + 'additionalProperties': False, + } + + def load_autoinstall_data(self, data): + if data is not None: + identity_data = WSLConfigurationBase( + custom_path=data['custom_path'], + custom_mount_opt=data['custom_mount_opt'], + gen_host=data['gen_host'], + gen_resolvconf=data['gen_resolvconf'], + ) + self.model.apply_settings(identity_data, self.opts.dry_run) + + @with_context() + async def apply_autoinstall_config(self, context=None): + pass + + def make_autoinstall(self): + r = attr.asdict(self.model.wslconfbase) + return r + + async def GET(self) -> WSLConfigurationBase: + data = WSLConfigurationBase() + if self.model.wslconfbase is not None: + data.custom_path = self.model.wslconfbase.custom_path + data.custom_mount_opt = self.model.wslconfbase.custom_mount_opt + data.gen_host = self.model.wslconfbase.gen_host + data.gen_resolvconf = self.model.wslconfbase.gen_resolvconf + return data + + async def POST(self, data: WSLConfigurationBase): + self.model.apply_settings(data, self.opts.dry_run) + self.configured() diff --git a/system_setup/server/server.py b/system_setup/server/server.py new file mode 100644 index 00000000..b73acf8e --- /dev/null +++ b/system_setup/server/server.py @@ -0,0 +1,59 @@ +# 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 . + +from subiquity.server.server import SubiquityServer +from system_setup.models.system_setup import SystemSetupModel +from subiquity.models.subiquity import ModelNames + +import os + + +INSTALL_MODEL_NAMES = ModelNames({ + "locale", + "wslconfbase", + }, + wsl_setup={ + "identity", + }, + wsl_configuration={ + "wslconfadvanced", + }) + +POSTINSTALL_MODEL_NAMES = ModelNames(set()) + + +class SystemSetupServer(SubiquityServer): + + from system_setup.server import controllers as controllers_mod + controllers = [ + "Reporting", + "Error", + "Locale", + "Identity", + "WSLConfigurationBase", + "WSLConfigurationAdvanced", + "Configure", + "Late", + "SetupShutdown", + ] + + supported_variants = ["wsl_setup", "wsl_configuration"] + + def make_model(self): + root = '/' + if self.opts.dry_run: + root = os.path.abspath('.subiquity') + return SystemSetupModel(root, INSTALL_MODEL_NAMES, + POSTINSTALL_MODEL_NAMES) diff --git a/system_setup/ui/__init__.py b/system_setup/ui/__init__.py new file mode 100644 index 00000000..8290406c --- /dev/null +++ b/system_setup/ui/__init__.py @@ -0,0 +1,14 @@ +# 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 . diff --git a/system_setup/ui/views/__init__.py b/system_setup/ui/views/__init__.py new file mode 100644 index 00000000..524b68f9 --- /dev/null +++ b/system_setup/ui/views/__init__.py @@ -0,0 +1,26 @@ +# 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 . + +from .identity import WSLIdentityView +from .wslconfbase import WSLConfigurationBaseView +from .wslconfadvanced import WSLConfigurationAdvancedView +from .summary import SummaryView + +__all__ = [ + 'WSLIdentityView', + 'WSLConfigurationBaseView', + 'WSLConfigurationAdvancedView', + 'SummaryView', +] diff --git a/system_setup/ui/views/identity.py b/system_setup/ui/views/identity.py new file mode 100644 index 00000000..4e5788f6 --- /dev/null +++ b/system_setup/ui/views/identity.py @@ -0,0 +1,89 @@ +# 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 . + +import os +from urwid import ( + connect_signal, + ) + + +from subiquity.common.types import IdentityData +from subiquity.ui.views.identity import ( + IdentityForm, + IdentityView, + setup_password_validation, + ) +from subiquitycore.ui.utils import screen +from subiquitycore.utils import crypt_password +from subiquitycore.view import BaseView + +from subiquity.common.resources import resource_path + + +class WSLIdentityForm(IdentityForm): + + realname = IdentityForm.realname + username = IdentityForm.username + username.help = \ + _("The username does not need to match your Windows username") + password = IdentityForm.password + confirm_password = IdentityForm.confirm_password + + +class WSLIdentityView(BaseView): + title = IdentityView.title + excerpt = _("Please create a default UNIX user account. " + "For more information visit: https://aka.ms/wslusers") + + def __init__(self, controller, identity_data): + self.controller = controller + + reserved_usernames_path = resource_path('reserved-usernames') + reserved_usernames = set() + if os.path.exists(reserved_usernames_path): + with open(reserved_usernames_path) as fp: + for line in fp: + line = line.strip() + if line.startswith('#') or not line: + continue + reserved_usernames.add(line) + else: + reserved_usernames.add('root') + + initial = { + 'realname': identity_data.realname, + 'username': identity_data.username, + } + + # This is the different form model with IdentityView + # which prevents us from inheriting it + self.form = WSLIdentityForm([], initial) + + connect_signal(self.form, 'submit', self.done) + setup_password_validation(self.form, _("passwords")) + + super().__init__( + screen( + self.form.as_rows(), + [self.form.done_btn], + excerpt=_(self.excerpt), + focus_buttons=False)) + + def done(self, result): + self.controller.done(IdentityData( + realname=self.form.realname.value, + username=self.form.username.value, + crypted_password=crypt_password(self.form.password.value), + )) diff --git a/system_setup/ui/views/summary.py b/system_setup/ui/views/summary.py new file mode 100644 index 00000000..a65e4019 --- /dev/null +++ b/system_setup/ui/views/summary.py @@ -0,0 +1,90 @@ +""" Summary + +Summary provides user with the summary of all the current settings. + +""" + +import logging + +from subiquitycore.ui.utils import button_pile, screen +from subiquitycore.view import BaseView +from subiquitycore.ui.form import Toggleable +from subiquitycore.ui.buttons import ( + cancel_btn, + ok_btn, + ) +from subiquitycore.ui.width import widget_width +from subiquity.common.types import ApplicationState + + +log = logging.getLogger("ubuntu_wsl_oobe.ui.views.summary") + + +class SummaryView(BaseView): + title = _("Setup Complete") + + def __init__(self, controller, real_name): + self.controller = controller + complete_text = _("Hi {real_name},\n\n" + "You have completed the setup!\n\n" + "It is suggested to run the following commands" + " to update your Ubuntu to the latest version:" + "\n\n\n" + " $ sudo apt update\n $ sudo apt upgrade\n\n\n" + "All settings will take effect after next " + "restart of Ubuntu.").format(real_name=real_name) + + self.reboot_btn = Toggleable(ok_btn( + _("Reboot Now"), on_press=self.reboot)) + self.view_error_btn = cancel_btn( + _("View error report"), on_press=self.view_error) + + self.event_buttons = button_pile([]) + super().__init__( + screen( + rows=[], + buttons=self.event_buttons, + focus_buttons=True, + excerpt=complete_text, + ) + ) + + def update_for_state(self, state): + btns = [] + if state == ApplicationState.DONE: + btns = [self.reboot_btn] + elif state == ApplicationState.ERROR: + self.title = _('An error occurred during installation') + self.reboot_btn.base_widget.set_label(_("Reboot Now")) + self.reboot_btn.enabled = True + btns = [ + self.view_error_btn, + self.reboot_btn, + ] + else: + raise Exception(state) + if self.controller.showing: + self.controller.app.ui.set_header(self.title) + self._set_buttons(btns) + + def reboot(self, btn): + log.debug('reboot clicked') + self.reboot_btn.base_widget.set_label(_("Rebooting...")) + self.reboot_btn.enabled = False + self.event_buttons.original_widget._select_first_selectable() + self.controller.click_reboot() + self._set_button_width() + + def view_error(self, btn): + self.controller.app.show_error_report(self.controller.crash_report_ref) + + def _set_button_width(self): + w = 14 + for b, o in self.event_buttons.original_widget.contents: + w = max(widget_width(b), w) + self.event_buttons.width = self.event_buttons.min_width = w + + def _set_buttons(self, buttons): + p = self.event_buttons.original_widget + p.contents[:] = [(b, p.options('pack')) for b in buttons] + self._set_button_width() diff --git a/system_setup/ui/views/wslconfadvanced.py b/system_setup/ui/views/wslconfadvanced.py new file mode 100644 index 00000000..6dc51f27 --- /dev/null +++ b/system_setup/ui/views/wslconfadvanced.py @@ -0,0 +1,200 @@ +""" WSLConfigurationAdvanced View + +WSLConfigurationAdvanced provides user with options with additional settings +for advanced configuration. + +""" +import re + +from urwid import ( + connect_signal, +) + +from subiquitycore.ui.form import ( + Form, + BooleanField, + ChoiceField, + simple_field, + WantsToKnowFormField +) +from subiquitycore.ui.interactive import StringEditor +from subiquitycore.ui.utils import screen +from subiquitycore.view import BaseView +from subiquity.common.types import WSLConfigurationAdvanced + + +class MountEditor(StringEditor, WantsToKnowFormField): + def keypress(self, size, key): + ''' restrict what chars we allow for mountpoints ''' + + mountpoint = r'[a-zA-Z0-9_/\.\-]' + if re.match(mountpoint, key) is None: + return False + + return super().keypress(size, key) + + +MountField = simple_field(MountEditor) +StringField = simple_field(StringEditor) + + +# TODO WSL: Advanced should not contain base configuration +# (it must be in 2 pages). + +class WSLConfigurationAdvancedForm(Form): + def __init__(self, initial): + super().__init__(initial=initial) + + automount = BooleanField(_("Enable Auto-Mount"), + help=_("Whether the Auto-Mount freature is" + " enabled. This feature allows you " + "to mount Windows drive in WSL")) + mountfstab = BooleanField(_("Mount `/etc/fstab`"), + help=_("Whether `/etc/fstab` will be mounted." + " The configuration file `/etc/fstab` " + "contains the necessary information to" + " automate the process of mounting " + "partitions. ")) + custom_path = MountField(_("Auto-Mount Location"), + help=_("Location for the automount")) + custom_mount_opt = StringField(_("Auto-Mount Option"), + help=_("Mount option passed for " + "the automount")) + gen_host = BooleanField(_("Enable Host Generation"), help=_( + "Selecting enables /etc/host re-generation at every start")) + gen_resolvconf = BooleanField(_("Enable resolv.conf Generation"), help=_( + "Selecting enables /etc/resolv.conf re-generation at every start")) + interop_enabled = BooleanField(_("Enable Interop"), + help=_("Whether the interoperability is" + " enabled")) + interop_appendwindowspath = BooleanField(_("Append Windows Path"), + help=_("Whether Windows Path " + "will be append in the" + " PATH environment " + "variable in WSL.")) + gui_theme = ChoiceField(_("GUI Theme"), + help=_("This option changes the Ubuntu theme."), + choices=["default", "light", "dark"]) + gui_followwintheme = BooleanField(_("Follow Windows Theme"), + help=_("This option manages whether the" + " Ubuntu theme follows the " + "Windows theme; that is, when " + "Windows uses dark theme, " + "Ubuntu also uses dark theme." + " Requires WSL interoperability" + " enabled. ")) + legacy_gui = BooleanField(_("Legacy GUI Integration"), + help=_("This option enables the Legacy GUI " + "Integration on Windows 10. Requires" + " a Third-party X Server.")) + legacy_audio = BooleanField(_("Legacy Audio Integration"), + help=_("This option enables the Legacy " + "Audio Integration on Windows 10. " + "Requires PulseAudio for " + "Windows Installed.")) + adv_ip_detect = BooleanField(_("Advanced IP Detection"), + help=_("This option enables advanced " + "detection of IP by Windows " + "IPv4 Address which is more " + "reliable to use with WSL2. " + "Requires WSL interoperability" + " enabled.")) + wsl_motd_news = BooleanField(_("Enable WSL News"), + help=_("This options allows you to control" + " your MOTD News. Toggling it on " + "allows you to see the MOTD.")) + + def validate_custom_path(self): + p = self.custom_path.value + if p != "" and (re.fullmatch(r"(/[^/ ]*)+/?", p) is None): + return _("Mount location must be a absolute UNIX path" + " without space.") + + def validate_custom_mount_opt(self): + o = self.custom_mount_opt.value + # filesystem independent mount option + fsimo = [r"async", r"(no)?atime", r"(no)?auto", + r"(fs|def|root)?context=\w+", r"(no)?dev", r"(no)?diratime", + r"dirsync", r"(no)?exec", r"group", r"(no)?iversion", + r"(no)?mand", r"_netdev", r"nofail", r"(no)?relatime", + r"(no)?strictatime", r"(no)?suid", r"owner", r"remount", + r"ro", r"rw", r"_rnetdev", r"sync", r"(no)?user", r"users"] + # DrvFs filesystem mount option + drvfsmo = r"case=(dir|force|off)|metadata|(u|g)id=\d+|(u|f|d)mask=\d+|" + fso = "{0}{1}".format(drvfsmo, '|'.join(fsimo)) + + if o != "": + e_t = "" + p = o.split(',') + x = True + for i in p: + if i == "": + e_t += _("an empty entry detected; ") + x = x and False + elif re.fullmatch(fso, i) is not None: + x = x and True + else: + e_t += _("{} is not a valid mount option; ").format(i) + x = x and False + if not x: + return _("Invalid Input: {}Please check " + "https://docs.microsoft.com/en-us/windows/wsl/" + "wsl-config#mount-options " + "for correct valid input").format(e_t) + + +class WSLConfigurationAdvancedView(BaseView): + title = _("WSL advanced options") + excerpt = _("In this page, you can configure Ubuntu WSL" + "advanced options your needs. \n") + + def __init__(self, controller, configuration_data): + self.controller = controller + + initial = { + 'custom_path': configuration_data.custom_path, + 'custom_mount_opt': configuration_data.custom_mount_opt, + 'gen_host': configuration_data.gen_host, + 'gen_resolvconf': configuration_data.gen_resolvconf, + 'interop_enabled': configuration_data.interop_enabled, + 'interop_appendwindowspath': + configuration_data.interop_appendwindowspath, + 'gui_theme': configuration_data.gui_theme, + 'gui_followwintheme': configuration_data.gui_followwintheme, + 'legacy_gui': configuration_data.legacy_gui, + 'legacy_audio': configuration_data.legacy_audio, + 'adv_ip_detect': configuration_data.adv_ip_detect, + 'wsl_motd_news': configuration_data.wsl_motd_news, + 'automount': configuration_data.automount, + 'mountfstab': configuration_data.mountfstab, + } + self.form = WSLConfigurationAdvancedForm(initial=initial) + + connect_signal(self.form, 'submit', self.done) + super().__init__( + screen( + self.form.as_rows(), + [self.form.done_btn], + focus_buttons=True, + excerpt=self.excerpt, + ) + ) + + def done(self, result): + self.controller.done(WSLConfigurationAdvanced( + custom_path=self.form.custom_path.value, + custom_mount_opt=self.form.custom_mount_opt.value, + gen_host=self.form.gen_host.value, + gen_resolvconf=self.form.gen_resolvconf.value, + interop_enabled=self.form.interop_enabled.value, + interop_appendwindowspath=self.form + .interop_appendwindowspath.value, + gui_theme=self.form.gui_theme.value, + gui_followwintheme=self.form.gui_followwintheme.value, + legacy_gui=self.form.legacy_gui.value, + legacy_audio=self.form.legacy_audio.value, + adv_ip_detect=self.form.adv_ip_detect.value, + wsl_motd_news=self.form.wsl_motd_news.value, + automount=self.form.automount.value, + mountfstab=self.form.mountfstab.value, + )) diff --git a/system_setup/ui/views/wslconfbase.py b/system_setup/ui/views/wslconfbase.py new file mode 100644 index 00000000..96086d1b --- /dev/null +++ b/system_setup/ui/views/wslconfbase.py @@ -0,0 +1,125 @@ +""" WSLConfBase + +WSLConfBase provides user with options to set up basic WSL configuration, +requested on first setup. + +""" +import re + +from urwid import ( + connect_signal, +) + +from subiquitycore.ui.form import ( + Form, + BooleanField, + simple_field, + WantsToKnowFormField +) +from subiquitycore.ui.interactive import StringEditor +from subiquitycore.ui.utils import screen +from subiquitycore.view import BaseView +from subiquity.common.types import WSLConfigurationBase + + +class MountEditor(StringEditor, WantsToKnowFormField): + def keypress(self, size, key): + ''' restrict what chars we allow for mountpoints ''' + + mountpoint = r'[a-zA-Z0-9_/\.\-]' + if re.match(mountpoint, key) is None: + return False + + return super().keypress(size, key) + + +MountField = simple_field(MountEditor) +StringField = simple_field(StringEditor) + + +class WSLConfBaseForm(Form): + def __init__(self, initial): + super().__init__(initial=initial) + + custom_path = MountField(_("Mount Location"), + help=_("Location for the automount")) + custom_mount_opt = StringField(_("Mount Option"), + help=_("Mount option passed " + "for the automount")) + gen_host = BooleanField(_("Enable Host Generation"), help=_( + "Selecting enables /etc/host re-generation at every start")) + gen_resolvconf = BooleanField(_("Enable resolv.conf Generation"), help=_( + "Selecting enables /etc/resolv.conf re-generation at every start")) + + def validate_custom_path(self): + p = self.custom_path.value + if p != "" and (re.fullmatch(r"(/[^/ ]*)+/?", p) is None): + return _("Mount location must be a absolute UNIX path" + " without space.") + + def validate_custom_mount_opt(self): + o = self.custom_mount_opt.value + # filesystem independent mount option + fsimo = [r"async", r"(no)?atime", r"(no)?auto", + r"(fs|def|root)?context=\w+", r"(no)?dev", r"(no)?diratime", + r"dirsync", r"(no)?exec", r"group", r"(no)?iversion", + r"(no)?mand", r"_netdev", r"nofail", r"(no)?relatime", + r"(no)?strictatime", r"(no)?suid", r"owner", r"remount", + r"ro", r"rw", r"_rnetdev", r"sync", r"(no)?user", r"users"] + # DrvFs filesystem mount option + drvfsmo = r"case=(dir|force|off)|metadata|(u|g)id=\d+|(u|f|d)mask=\d+|" + fso = "{0}{1}".format(drvfsmo, '|'.join(fsimo)) + + if o != "": + e_t = "" + p = o.split(',') + x = True + for i in p: + if i == "": + e_t += _("an empty entry detected; ") + x = x and False + elif re.fullmatch(fso, i) is not None: + x = x and True + else: + e_t += _("{} is not a valid mount option; ").format(i) + x = x and False + if not x: + return _("Invalid Input: {}Please check " + "https://docs.microsoft.com/en-us/windows/wsl/" + "wsl-config#mount-options " + "for correct valid input").format(e_t) + + +class WSLConfigurationBaseView(BaseView): + title = _("WSL configuration options") + excerpt = _( + "In this page, you can configure Ubuntu WSL options to your needs.\n") + + def __init__(self, controller, configuration_data): + self.controller = controller + + initial = { + 'custom_path': configuration_data.custom_path, + 'custom_mount_opt': configuration_data.custom_mount_opt, + 'gen_host': configuration_data.gen_host, + 'gen_resolvconf': configuration_data.gen_resolvconf, + } + self.form = WSLConfBaseForm(initial=initial) + + connect_signal(self.form, 'submit', self.done) + super().__init__( + screen( + self.form.as_rows(), + [self.form.done_btn], + focus_buttons=True, + excerpt=self.excerpt, + ) + ) + + def done(self, result): + self.controller.done(WSLConfigurationBase( + custom_path=self.form.custom_path.value, + custom_mount_opt=self.form.custom_mount_opt.value, + gen_host=self.form.gen_host.value, + gen_resolvconf=self.form.gen_resolvconf.value + ))