Compare commits

...

77 Commits

Author SHA1 Message Date
Dan Bungert ba42c5eed7
Merge pull request #1794 from dbungert/lunar-security-pocket
lunar: security archive
2023-09-12 07:16:43 -06:00
Olivier Gayot c1166b1e0d mirror: do not let curtin decide the URL of the security archive
When the URL of the security archive is unset, curtin will set it to the
URL of the primary archive.

This is not the behavior we want for Ubuntu installations. On amd64 (and
i386), the URL of the security archive should be set to
http://security.ubuntu.com/ubuntu

On other architectures, it should be set to
http://ports.ubuntu.com/ubuntu-ports

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 5556313652)
2023-09-11 18:24:53 -06:00
Olivier Gayot efb1bd8ae5 mirror: for mirror-testing, disable the security suite
Mirror testing should focus on testing the primary mirror, not the
security archive - therefore we disable the -security suite.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 79f2c4c432)
2023-09-11 18:16:31 -06:00
Olivier Gayot 7ce11afd57
Merge pull request #1760 from ogayot/lp2013201-lunar
2023-08-09 - Backport manual size fix to ubuntu/lunar
2023-08-09 17:27:40 +02:00
Olivier Gayot b53731356c filesystem: store the actual size in bytes alongside the human readable size
Currently, the partition form stores the size as a human readable value.
(e.g., 123456K, 1.1G, 1.876G, 100G). When we exit the size field (i.e., upon
losing focus), we convert the value to a number of bytes and then align
it up to the nearest MiB (or whatever the alignment requirement is).

Unfortunately, after computing the aligned value, we turn it back into a
human-readable string and store it as is. It is not okay because the
conversion does not ensure that the alignment requirement is still
honored.

For instance, if the user types in 1.1G, we do the following:

 * convert it to a number of bytes -> 1181116006.4 (surprise, it is not
   even an integer).

 * round it up to the nearest MiB -> 1181745152 (this is the correct
   value)

 * transform it into a human readable string and store it as is -> 1.1G
   - which actually corresponds to the original value.

This leads to an exception later when creating the partition:

    File "subiquity/models/filesystem.py", line 1841, in add_partition
      raise Exception(
   Exception: ('size %s or offset %s not aligned to %s', 1181116006, 1048576, 1048576)

Fixed by storing the actual size as a number of bytes - alongside the
human readable size.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit cda6c54b87)
2023-08-09 17:01:44 +02:00
Dan Bungert 0902246d8b
Merge pull request #1665 from dbungert/lunar-curtin-ntfs
snapcraft: rev for ntfsresize change
2023-04-18 19:22:59 +01:00
Dan Bungert 0b4fc4153d snapcraft: rev for ntfsresize change 2023-04-18 19:12:10 +01:00
Dan Bungert 3bddb3632b
Merge pull request #1663 from dbungert/lunar-no-probert-during-partitioning
Lunar no probert during partitioning
2023-04-18 19:10:06 +01:00
Olivier Gayot f1e24f3580 storage: add API test to cover probert run finishing during partitioning
Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 9e4cd77d9d)
2023-04-18 18:56:41 +01:00
Olivier Gayot 7057f77c49 storage: add unit tests to cover probe data locking
Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit d8748c27fe)
2023-04-18 18:56:41 +01:00
Olivier Gayot d7ce2e5048 storage: don't let probing discard storage config in the middle of partitioning
If a probert run finished in the middle of partitioning, the probing
data would be loaded automatically and this would essentially discard
changes made so far by the user. This is not a desirable behavior.

Upon starting partitioning, we now prevent later probert runs from
automatically loading the probe data. Instead, the data is stored and
only loaded after after a reset.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit cd25ec454f)
2023-04-18 18:50:03 +01:00
Dan Bungert bfeee32e48
Merge pull request #1662 from ogayot/lunar-2023-04-17
Lunar 2023 04 17
2023-04-17 02:18:07 -06:00
Olivier Gayot d3b27b116b snapcraft: bump probert to pickup verbosity fix
Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 55a5ebd42b)
2023-04-17 09:21:49 +02:00
Olivier Gayot cc01782fb7 mirror: clean environment before running apt-get
For mirror testing, we run apt-get update on the host. By default,
external commands run by subiquity inherit the environment variables set
by the snap (including LD_LIBRARY_PATH).

We don't ship apt-get or its dependencies in the subiquity snap, so we
want to reset LD_LIBRARY_PATH and other variables when running the
command.

Not doing so leads to the following error when running the subiquity
snap on a focal-based system:

 * apt-get: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.33' not
   found (required by /snap/subiquity/4675/usr/lib/x86_64-linux-gnu/libsystemd.so.0)
 * apt-get: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.32' not
   found (required by /snap/subiquity/4675/usr/lib/x86_64-linux-gnu/libsystemd.so.0)
 * apt-get: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not
   found (required by /snap/subiquity/4675/usr/lib/x86_64-linux-gnu/libsystemd.so.0)
 * apt-get: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.33' not
   found (required by /snap/core22/current/lib/x86_64-linux-gnu/libcap.so.2)

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 3bb74546e2)
2023-04-17 09:21:38 +02:00
Olivier Gayot 96f09a7004 filesystem: fix crash when editing swap partition in v2
When we create a swap partition using v2_add_partition, the mount
parameter always has the value of None.

We do however, create a fake mountpoint object and assign it the value
of "" (empty string) so we know the swap partition is used although it's
not really mounted anywhere in the filesystem hierarchy.

Subsequently, when editing the swap partition, the mount parameter is
set to the empty string, not None.

This causes trouble in the create_mount function, which normally returns
immediately upon encountering None as the mountpoint.

Fixed by skipping the call to create_mount for a swap partition.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit aa6274c38e)
2023-04-17 09:21:28 +02:00
Dan Bungert 6ba87d99c9
Merge pull request #1658 from dbungert/lunar-2023-04-13-2
Fixed paths for lsc and locales commands
2023-04-13 14:31:46 -06:00
Carlos Nihelton 3f179bb73d Fixed paths for lsc and locales commands
Subiquity snap doesn't stage language-selector-common nor locales
packages.
Attempting to run those commands require respecting the env previously
prepared by the caller, as well as fixing the commands paths
(or changing the PATH env var in the env preparation step).
Since the apt commands were already ran with a fixed path,
I'm applying the same principle in here.
Yet, for now, Jammy doesn't ship the l-s-c package seeded,
so a condition was preserved to avoid future crashes.

(cherry picked from commit 2e67005403)
2023-04-13 14:23:14 -06:00
Dan Bungert c5aebd80b1
Merge pull request #1653 from dbungert/lunar-2023-04-13
Lunar 2023 04 13
2023-04-13 10:36:48 -06:00
Dan Bungert 8f647e0971 errorreport: tag report with snap name
(cherry picked from commit cd03fea2f0)
2023-04-13 10:23:03 -06:00
Dan Bungert ea2fca5cb0 errorreport: add tags capability
Future problem_report has a nice add_tags method, simulate it here.
Should be able to drop this with a core24 move.

(cherry picked from commit 232f2f9d8c)
2023-04-13 10:23:03 -06:00
Olivier Gayot b6dc275817 storage: fix crash when editing encrypted VG created in guided storage
When editing an encrypted VG that was created in the guided storage
screen, the VG information is originating from the server. However, the
server does not send the LUKS key over the wire. Instead it sends the
path to a keyfile which contains the key. The client may or may not have
read access to this keyfile so it does not have a reliable way to
determine the key.

This causes problem when editing the VG because the GUI expects to
receive a key when encryption is enabled.

If the VG object only contains a keyfile, the passphrase is set to None
and this result in the GUI crashing.

This patch fixes the crash by passing an empty passphrase instead of a
None value when the VG object only contains a keyfile.

This means the user gets forced to supply a passphrase again when
editing an encrypted VG that was created in the guided partition screen.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit dcc66fa346)
2023-04-13 10:22:51 -06:00
Dan Bungert d549c2bdbb
Merge pull request #1649 from dbungert/lunar-2023-04-12
Lunar 2023 04 12
2023-04-12 10:00:24 -06:00
Olivier Gayot 4722feba26 api: avoid post-ing to source in autoinstalls
When running autoinstalls, the source model will be marked configured
automatically after apply_autoinstall_config has run. The selected
source will be the one described in the autoinstall section or will
default to the legacy server entry: Ubuntu Server.

Doing an additional POST request can only make things inconsistent in
fully automated installs.

When the POST request is handled, most of the models may already have
applied their autoinstall configuration, and are already relying on the
previous source selected.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 8298ec17c8)
2023-04-12 17:52:09 +02:00
Olivier Gayot 57741451e2 mirror: bail if source changes after applying autoinstall config
If the source model changes, the mirror model gets a chance to apply a
new apt configuration. However, if this happens during an automated
install, this is a recipe to disaster.

Make sure we raise an exception if a POST request to /source occurs
after the mirror model has started applying its autoinstall
configuration.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 6aa9452bb0)
2023-04-12 17:52:06 +02:00
Olivier Gayot 85420754cd mirror: avoid assertion error if the source changes at the wrong time
When the source model changes, the mirror model gets notified and
replaces the "apt configurer" instance with a new one. If this happens
in the middle of a "deploy apt configuration + run apt-get update"
operation, this leads to an assertion error.

Fixed by making sure we do the entire operation on the same "apt
configurer" instance.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 938f2ffc24)
2023-04-12 17:52:04 +02:00
Dan Bungert 4e66da5bbc snapcraft: rev probert for diagnostics
(cherry picked from commit fd1fc1c1d3)
2023-04-12 09:31:40 -06:00
Dan Bungert 0fd2b9c642 errorreport: get udi log
(cherry picked from commit 8a71b7d1ed)
2023-04-12 09:31:38 -06:00
Dan Bungert a92bdd4f2b install: make unattended-upgrades non-fatal
Log the result if failed, ensure that this is also journal-visible.

(cherry picked from commit 800a1e9372)
2023-04-12 09:31:35 -06:00
Dan Bungert 5f7598b3c8 runner: show stdout/stderr on exception
(cherry picked from commit e71a9f98cd)
2023-04-12 09:31:35 -06:00
Dan Bungert 28d127ae20 utils: stdout/stderr logger
(cherry picked from commit 7c5a7f4289)
2023-04-12 09:31:35 -06:00
Dan Bungert dd113c14ab scripts: update slimy to handle u-d-i
If the target snap is ubuntu-desktop-installer, put subiquity changes in
the appropriate location.

(cherry picked from commit b72a5397fb)
2023-04-12 09:31:33 -06:00
Dan Bungert 30e2f184a4
Merge pull request #1645 from dbungert/lunar-pr-1639
network: don't run netplan apply if no autoinstall and nm is enabled
2023-04-12 09:11:39 -06:00
Dan Bungert 79eb2af97f
Merge pull request #1644 from dbungert/lunar-2023-04-11
Lunar 2023 04 11
2023-04-12 07:39:53 -06:00
Olivier Gayot 54d7bb1bf6 network: don't run netplan apply if no autoinstall and nm is enabled
Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
Co-authored-by: Dan Bungert <daniel.bungert@canonical.com>
(cherry picked from commit 9d32ded2f9)
2023-04-11 16:56:55 -06:00
Dan Bungert 8297c6a65e test/api: regression test for LP: #2015521
(cherry picked from commit fa81e0981e)
2023-04-11 16:50:28 -06:00
Dan Bungert 8795ce819a gaps: add unittests for gap before part on msdos
(cherry picked from commit dc810414d8)
2023-04-11 16:50:28 -06:00
Dan Bungert 7ae5b3aae1 gaps: fix gap when before a partition in extended
We need the ebr space if we're doing logical partitions, but not for the
primaries.

(cherry picked from commit 5219ecf50c)
2023-04-11 16:50:28 -06:00
Dan Bungert 8caa2cf9c2 locale: switch to localectl set-locale
Move away from localedef to fully set the locale.

(cherry picked from commit fb2c6da2e6)
2023-04-11 16:50:28 -06:00
Dan Bungert d1b1dce0df
Merge pull request #1638 from dbungert/lunar-2023-04-05
Lunar 2023 04 05
2023-04-05 07:50:24 -06:00
Olivier Gayot 5cc01432f3 keyboard: fix use of multi-layout keyboard layout
The XKB configuration guide shows that multi-layout configuration is
possible, as in the following example:

    Option "XkbLayout" "us,cz,de"
    Option "XkbVariant" ",bksl,"

which translates to:
 * layout "us" with no variant
 * layout "cz" with variant "bksl"
 * layout "de" with no variant

The same applies to /etc/default/keyboard.

We do make use of this ability to automatically set an alternative
keyboard layout for non latin keyboard layouts (e.g., Arabic "ara"
becomes "us,ara" so that users can toggle between American and Arabic
keyboard layouts.

In the following commit:

  13cae2488 keyboard: validate layout and variant

.. we started implementing early validation of the keyboard layout, but
we did not consider that multi-layouts are supported.

As a result, subiquity fails at keyboard selection when a non latin
keyboard layout is selected.

Fixed by making sure the validation expects possible multi-layout
setups.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit a91dbfa004)
2023-04-05 07:32:07 -06:00
Dan Bungert 13c2fdff1a storage: restricted probes must gather fs info
In ubuntu-desktop-installer/issues/1772, a user formatted an existing
partition as ext4, which triggered the ESP to be "mounted".  Except this
mount step also formatted the ESP.

Previously, when we ran block probing, the restricted=False version
failed, which caused it to run a restricted=True probe.  The
restricted=True probe looks at block devices, but does not gather
filesystem information.  The esp "mount" check is also willing to format
the partition if it is unformatted.  (We do not have a distinction
between a partition that is unformatted and one for which we have not
obtained filesystem information.)

This user had block probing restricted=False fail due to LP: #2012722,
where Ventoy was in use and we handled the device mapper entry poorly.
While recreating the failure case, simply retesting with a build with
the fix for LP: #2012722 is enough to avoid this problem.  This is
because the restricted=False probe passes, which means filesystem info
is gathered, which means the mount step doesn't attempt to format it.

There will inevitably be other block probing failure cases for
restricted=False.  restricted=True must not leave people so vulnerable
to a partition reformat.

Gather filesystem information during a restricted=True probe.

(cherry picked from commit c921192db3)
2023-04-05 07:31:57 -06:00
Dan Bungert d8f30ed36a
Merge pull request #1635 from dbungert/lunar-2023-04-04
Lunar 2023 04 04
2023-04-04 09:03:47 -06:00
Dan Bungert ef99122a9c storage/v2: remove potential_boot_disks
(cherry picked from commit 965a49f28f)
2023-04-04 08:22:55 -06:00
Dan Bungert 7421bf7114 storage/v2: mark can_be_boot_device on disks
(cherry picked from commit 3a0e9ed425)
2023-04-04 08:22:55 -06:00
Dan Bungert 8533f57e9d
Merge pull request #1633 from dbungert/lunar-sizing-policy
Lunar sizing policy
2023-04-04 08:10:26 -06:00
Dan Bungert d49e37edd0 utils: matching_dicts handle key=None, typing
Co-authored-by: Olivier Gayot <olivier.gayot@canonical.com>
2023-04-03 15:22:30 -06:00
Dan Bungert 6c2e52a028 storage: autoinstall sizing-policy
Co-authored-by: Ryan Mounce <ryan@mounce.com.au>
2023-04-03 15:22:30 -06:00
Dan Bungert 9eeb5669f2 storage: implement unscaled LV sizing for guided 2023-04-03 15:22:30 -06:00
Dan Bungert dcb8fe7920 sizing: move lv scaling logic 2023-04-03 15:22:30 -06:00
Dan Bungert e0e856475c util: add matching_dicts
Move some of this logic to a common util, so it can be used in
unittests.  The curtin version is close but expects to work on a storage
config, which is not a flat list.
2023-04-03 15:22:30 -06:00
Dan Bungert 2d74167614
Merge pull request #1632 from ogayot/lunar-2023-04-03
Lunar 2023 04 03
2023-04-03 14:46:32 -06:00
Olivier Gayot 7d3222dab9 snapcraft: update curtin to fix disk lookups with wwn extensions
Adjusted to pick commit from ubuntu/lunar branch.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 4c4ab16f7d)
2023-04-03 21:28:14 +02:00
Carlos Nihelton c36f4a81eb Falls back to seeded l-s-c
UDI snap ships the package, thus its data dir is part of the snap
subiquity snap does not.
This tries l-s-c data dir in the base system, outside of the snap
and gives up if the dir does not exits.

(cherry picked from commit 1105da2368)
2023-04-03 21:26:39 +02:00
Carlos Nihelton 53473feda4 Umounts in the reversed order.
Sometimes unmounting order matters. Let's take the defensive approach.

(cherry picked from commit cda5a0f250)
2023-04-03 21:26:12 +02:00
Carlos Nihelton c0e7390c61 Bind mounts
realm --install <target> requires system bind-mounts to work properly.

(cherry picked from commit 37f3348135)
2023-04-03 21:25:58 +02:00
Carlos Nihelton 9cf9a156c6 (fix) Join target instead of live session
LP: #2013079
Found during beta testing that the live session is joining, instead of
the target.

(cherry picked from commit cd6e69187a)
2023-04-03 21:24:40 +02:00
Dan Bungert 596c14056a
Merge pull request #1629 from dbungert/lunar-2023-03-31
Lunar 2023 03 31
2023-03-31 07:38:32 -06:00
Olivier Gayot 83ff5597e7 snapcraft: bump version of curtin to pickup ventoy fix
Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
2023-03-31 07:30:40 -06:00
Dan Bungert e5602ef5e5 cmd: log environment on startup 2023-03-31 07:26:30 -06:00
Dan Bungert 7da6b832d4 utils: orig_environ cleans LD_LIBRARY_PATH
LD_LIBRARY_PATH is set earlier than some of the other environment
variables like PATH or PYTHONPATH, so trying to save a clean version in
snapcraft is not viable.  Remove it here.
2023-03-31 07:26:30 -06:00
Dan Bungert e33b0d9107
Merge pull request #1626 from dbungert/lunar-2023-03-30
Lunar 2023 03 30
2023-03-30 08:27:28 -06:00
Olivier Gayot ade3416d62 filesystem: fix path property from Raid - override attribute
The following commmit:
  ce146ab28 add .path to Raid so for_client(raid-with-partitions) works

introduced a (read only) path property for Raid objects, returning an
imaginary value to make for_client work with Raid objects. Later on, the
following commit:
  8e658998e add "path" attributes to fs model objects that curtin now
            provides a path for

introduced a (read/write) path attribute to all filesystem objects. For
Raid however, the property still takes precedence over the new attribute
of the same name, so doing raid.path = x is invalid (no setter) and
results in the following exception:

AttributeError: can't set attribute 'path'

Fixed by using a @property + @setter attribute for path. The getter
returns the actual path if it exists, otherwise returns the same
imaginary value as before.

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
2023-03-30 08:20:17 -06:00
Dan Bungert d66c193e54 errorreport: all recent syslog
Some outputs only appear in the journal.
2023-03-30 07:53:09 -06:00
Dan Bungert 8759105e08 system-setup: fix disappearing ubuntu-wsl-setup
Make a copy of this file to INSTALL, as the organize step renames it,
causing it to disappear from the source directory.
2023-03-30 07:53:09 -06:00
Dan Bungert 0207d1a1e7 network: run `netplan apply` without snap env
netplan isn't staged in the snap, and the environment variables being
passed around don't help it.
2023-03-30 07:53:08 -06:00
J-P Nurmi 2764719a30 Add test source catalog for desktop variants 2023-03-30 07:53:08 -06:00
Dan Bungert 48c3b0a7c7 system-setup: restore accidentally deleted file 2023-03-30 07:53:08 -06:00
Chad Smith 481e5c85db cloud-init: preserve ephemeral cloud-init logs in target to aid in debug 2023-03-30 07:53:08 -06:00
Michael Hudson-Doyle 5311840ad2 fix "make lint" when run with the new pycodestyle/flake8 2023-03-30 07:53:08 -06:00
Dan Bungert 7fd3e607ba workflow: run both linters always
If the linter is broken in the distro in the devel series but not the
LTS, such as right now, it's interesting to allow the LTS linter to
finish.
2023-03-30 07:53:08 -06:00
Dan Bungert 49bf10022c
Merge pull request #1607 from dbungert/lunar-2023-03-21
Lunar 2023 03 21
2023-03-21 19:02:01 -06:00
Dan Bungert e1686e4e80 ad: add hack to fix non-autoinstall cases 2023-03-21 18:38:25 -06:00
Carlos Nihelton 4ff3e645c3 Fix missing scrip in snap. 2023-03-21 18:37:24 -06:00
Olivier Gayot 113427345d network: fix check of wlan network state
Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
2023-03-21 18:37:05 -06:00
Dan Bungert fe8cf93d67 api: additional checks for controllers running 2023-03-21 18:36:45 -06:00
Olivier Gayot d9c29b919d filesystem: rename done btn which overrides a meth. of the same name
Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
2023-03-21 18:36:05 -06:00
Dan Bungert 7b78cbf3cf
Merge pull request #1599 from dbungert/lunar-2023-03-17
Lunar 2023 03 17
2023-03-17 10:20:07 -06:00
53 changed files with 1201 additions and 194 deletions

View File

@ -20,7 +20,7 @@ jobs:
lint: lint:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
strategy: strategy:
fail-fast: true fail-fast: false
matrix: matrix:
image: image:
- ubuntu-daily:jammy # match the core snap we're running against - ubuntu-daily:jammy # match the core snap we're running against

View File

@ -54,8 +54,8 @@ class RecoveryChooser(TuiApplication):
] ]
def __init__(self, opts, chooser_input, chooser_output): def __init__(self, opts, chooser_input, chooser_output):
"""Takes the options and raw input/output streams for communicating with the """Takes the options and raw input/output streams for communicating
chooser parent process. with the chooser parent process.
""" """
self._chooser_output = chooser_output self._chooser_output = chooser_output
# make_model is used by super()'s constructor, but we need to use the # make_model is used by super()'s constructor, but we need to use the

View File

@ -58,8 +58,8 @@ class ChooserBaseView(BaseView):
def by_preferred_action_type(action): def by_preferred_action_type(action):
"""Order action entries by having the 'run' mode first, then 'recover', then """Order action entries by having the 'run' mode first, then 'recover',
'install', the rest is ordered alphabetically.""" then 'install', the rest is ordered alphabetically."""
priority = {"run": 0, "recover": 1, "install": 2} priority = {"run": 0, "recover": 1, "install": 2}
return (priority.get(action.mode, 100), action.title.lower()) return (priority.get(action.mode, 100), action.title.lower())

View File

@ -311,6 +311,24 @@ When using the "lvm" layout, LUKS encryption can be enabled by supplying a passw
The default is to use the lvm layout. The default is to use the lvm layout.
#### sizing-policy
The lvm layout will, by default, attempt to leave room for snapshots and further expansion. A sizing-policy key may be supplied to control this behavior.
**type:** string (enumeration)
**default:** scaled
Supported values are:
* `scaled` -> adjust space allocated to the root LV based on space available to the VG
* `all` -> allocate all remaining VG space to the root LV
The scaling system is currently as follows:
* Less than 10 GiB: use all remaining space for root filesystem
* Between 10-20 GiB: 10 GiB root filesystem
* Between 20-200 GiB: use half of remaining space for root filesystem
* Greater than 200 GiB: 100 GiB root filesystem
#### action-based config #### action-based config
For full flexibility, the installer allows storage configuration to be done using a syntax which is a superset of that supported by curtin, described at https://curtin.readthedocs.io/en/latest/topics/storage.html. For full flexibility, the installer allows storage configuration to be done using a syntax which is a superset of that supported by curtin, described at https://curtin.readthedocs.io/en/latest/topics/storage.html.

View File

@ -0,0 +1,41 @@
- description:
en: A minimal but usable Ubuntu Desktop.
id: ubuntu-desktop-minimal
locale_support: langpack
name:
en: Ubuntu Desktop (minimized)
path: minimal.squashfs
preinstalled_langs:
- de
- en
- es
- fr
- it
- pt
- ru
- zh
- ''
size: 6135083008
type: fsimage-layered
variant: desktop
- default: true
description:
en: A full featured Ubuntu Desktop.
id: ubuntu-desktop
locale_support: langpack
name:
en: Ubuntu Desktop
path: minimal.standard.squashfs
preinstalled_langs:
- de
- en
- es
- fr
- it
- pt
- ru
- zh
- ''
size: 7604871168
type: fsimage-layered
variant: desktop

View File

@ -41,6 +41,18 @@ validate () {
echo "password leaked into log file" echo "password leaked into log file"
exit 1 exit 1
fi fi
# After the lunar release and the introduction of mirror testing, it
# came to our attention that new Ubuntu installations have the security
# repository configured with the primary mirror URL (i.e.,
# http://<cc>.archive.ubuntu.com/ubuntu) instead of
# http://security.ubuntu.com/ubuntu. Let's ensure we instruct curtin
# not to do that.
# If we run an autoinstall that customizes the security section as part
# of the test-suite, we will need to adapt this test.
python3 scripts/check-yaml-fields.py $tmpdir/var/log/installer/subiquity-curtin-apt.conf \
apt.security[0].uri='"http://security.ubuntu.com/ubuntu/"' \
apt.security[0].arches='["amd64", "i386"]' \
apt.security[1].uri='"http://ports.ubuntu.com/ubuntu-ports"'
netplan generate --root $tmpdir netplan generate --root $tmpdir
elif [ "${mode}" = "system_setup" ]; then elif [ "${mode}" = "system_setup" ]; then
setup_mode="$2" setup_mode="$2"

View File

@ -35,7 +35,9 @@ add_overlay() {
local upper="$(mktemp -dp "${tmpdir}")" local upper="$(mktemp -dp "${tmpdir}")"
fi fi
chmod go+rx "${work}" "${upper}" chmod go+rx "${work}" "${upper}"
do_mount -t overlay overlay -o lowerdir="${lower}",upperdir="${upper}",workdir="${work}" "${mountpoint}" do_mount -t overlay overlay \
-o lowerdir="${lower}",upperdir="${upper}",workdir="${work}" \
"${mountpoint}"
} }
@ -53,11 +55,22 @@ do_mount $old old
add_overlay old new add_overlay old new
rm -rf new/lib/python3.10/site-packages/curtin rm -rf new/lib/python3.10/site-packages/curtin
rm -rf new/lib/python3.10/site-packages/subiquity
rm -rf new/lib/python3.10/site-packages/subiquitycore if [ -d new/lib/python3.10/site-packages/subiquity ] ; then
subiquity_dest=new/lib/python3.10/site-packages
elif [ -d new/bin/subiquity/subiquity ] ; then
subiquity_dest=new/bin/subiquity
else
echo "unrecognized snap" >&2
exit 1
fi
rm -rf "${subiquity_dest}/subiquity"
rm -rf "${subiquity_dest}/subiquitycore"
(cd "${src}" && ./scripts/update-part.py curtin) (cd "${src}" && ./scripts/update-part.py curtin)
rsync -a --chown 0:0 $src/subiquity $src/subiquitycore $src/curtin/curtin new/lib/python3.10/site-packages rsync -a --chown 0:0 $src/curtin/curtin new/lib/python3.10/site-packages
rsync -a --chown 0:0 $src/subiquity $src/subiquitycore $subiquity_dest
snapcraft pack new --output $new snapcraft pack new --output $new

View File

@ -61,7 +61,8 @@ parts:
source: https://git.launchpad.net/curtin source: https://git.launchpad.net/curtin
source-type: git source-type: git
source-commit: b1f4da3bec92356e8ef389c1c581cfdcd1b36c42 # We're traking the branch ubuntu/lunar here.
source-commit: 9e9f66e835c79e00d17e61593cc01a66d055c2e9
override-pull: | override-pull: |
craftctl default craftctl default
@ -84,6 +85,15 @@ parts:
organize: organize:
lib/python*/site-packages/usr/lib/curtin: usr/lib/ lib/python*/site-packages/usr/lib/curtin: usr/lib/
ubuntu-wsl-setup:
plugin: nil
source: .
source-type: git
override-build: |
mkdir -p $CRAFT_PART_INSTALL/bin
cp system_setup/ubuntu-wsl-setup $CRAFT_PART_INSTALL/bin/ubuntu-wsl-setup
subiquity: subiquity:
plugin: nil plugin: nil
@ -134,7 +144,6 @@ parts:
bin/subiquity-service: usr/bin/subiquity-service bin/subiquity-service: usr/bin/subiquity-service
bin/subiquity-server: usr/bin/subiquity-server bin/subiquity-server: usr/bin/subiquity-server
bin/subiquity-cmd: usr/bin/subiquity-cmd bin/subiquity-cmd: usr/bin/subiquity-cmd
$CRAFT_PART_BUILD/system_setup/ubuntu-wsl-setup: bin/ubuntu-wsl-setup
build-attributes: build-attributes:
- enable-patchelf - enable-patchelf
@ -207,7 +216,7 @@ parts:
source: https://github.com/canonical/probert.git source: https://github.com/canonical/probert.git
source-type: git source-type: git
source-commit: 40479d08fce0370a0c41a140e6d322d2846c2a8f source-commit: dacf369e3dedc50018e4b3b86d4d919459da3cc6
override-build: *pyinstall override-build: *pyinstall

View File

@ -241,7 +241,7 @@ class FilesystemController(SubiquityTuiController, FilesystemManipulator):
clean_suffix='vg'): clean_suffix='vg'):
pass pass
elif action['action'] == 'done': elif action['action'] == 'done':
if not self.ui.body.done.enabled: if not self.ui.body.done_btn.enabled:
raise Exception("answers did not provide complete fs config") raise Exception("answers did not provide complete fs config")
await self.app.confirm_install() await self.app.confirm_install()
self.finish() self.finish()

View File

@ -159,10 +159,11 @@ def main():
logger = logging.getLogger('subiquity') logger = logging.getLogger('subiquity')
version = os.environ.get("SNAP_REVISION", "unknown") version = os.environ.get("SNAP_REVISION", "unknown")
logger.info("Starting Subiquity server revision {}".format(version)) snap = os.environ.get("SNAP", "unknown")
logger.info("Arguments passed: {}".format(sys.argv)) logger.info(f"Starting Subiquity server revision {version} of snap {snap}")
logger.debug("Kernel commandline: {}".format(opts.kernel_cmdline)) logger.info(f"Arguments passed: {sys.argv}")
logger.debug("Storage version: {}".format(opts.storage_version)) logger.debug(f"Kernel commandline: {opts.kernel_cmdline}")
logger.debug(f"Environment: {os.environ}")
async def run_with_loop(): async def run_with_loop():
server = SubiquityServer(opts, block_log_dir) server = SubiquityServer(opts, block_log_dir)
@ -171,6 +172,10 @@ def main():
"InstallerServerLog", logfiles['debug']) "InstallerServerLog", logfiles['debug'])
server.note_file_for_apport( server.note_file_for_apport(
"InstallerServerLogInfo", logfiles['info']) "InstallerServerLogInfo", logfiles['info'])
server.note_file_for_apport(
"UdiLog",
os.path.realpath(
"/var/log/installer/ubuntu_desktop_installer.log"))
await server.run() await server.run()
asyncio.run(run_with_loop()) asyncio.run(run_with_loop())

View File

@ -113,8 +113,10 @@ def main():
logger = logging.getLogger('subiquity') logger = logging.getLogger('subiquity')
version = os.environ.get("SNAP_REVISION", "unknown") version = os.environ.get("SNAP_REVISION", "unknown")
logger.info("Starting Subiquity revision {}".format(version)) snap = os.environ.get("SNAP", "unknown")
logger.info("Arguments passed: {}".format(sys.argv)) logger.info(f"Starting Subiquity TUI revision {version} of snap {snap}")
logger.info(f"Arguments passed: {sys.argv}")
logger.debug(f"Environment: {os.environ}")
if opts.answers is None and os.path.exists(AUTO_ANSWERS_FILE): if opts.answers is None and os.path.exists(AUTO_ANSWERS_FILE):
logger.debug("Autoloading answers from %s", AUTO_ANSWERS_FILE) logger.debug("Autoloading answers from %s", AUTO_ANSWERS_FILE)

View File

@ -60,6 +60,27 @@ def trim(text):
return text return text
async def check_controllers_started(definition, controller, request):
if not hasattr(controller, 'app'):
return
if getattr(definition, 'allowed_before_start', False):
return
# Most endpoints should not be responding to requests
# before the controllers have started, that's just bound to
# be a big pool of timing bugs that we want nothing to do
# with. A few chosen methods should be safe, so allow
# those to opt-in. Everybody else blocks until the
# controllers complete their start().
if controller.app.controllers_have_started.is_set():
return
log.debug(f'{request.path} waiting on start')
await controller.app.controllers_have_started.wait()
log.debug(f'{request.path} resuming')
def _make_handler(controller, definition, implementation, serializer, def _make_handler(controller, definition, implementation, serializer,
serialize_query_args): serialize_query_args):
def_sig = inspect.signature(definition) def_sig = inspect.signature(definition)
@ -134,18 +155,8 @@ def _make_handler(controller, definition, implementation, serializer,
args['context'] = context args['context'] = context
if 'request' in impl_params: if 'request' in impl_params:
args['request'] = request args['request'] = request
if not getattr( await check_controllers_started(
definition, 'allowed_before_start', False): definition, controller, request)
# Most endpoints should not be responding to requests
# before the controllers have started, that's just bound to
# be a big pool of timing bugs that we want nothing to do
# with. A few chosen methods should be safe, so allow
# those to opt-in. Everybody else blocks until the
# controllers complete their start().
if not controller.app.controllers_have_started.is_set():
log.debug(f'{request.path} waiting on start')
await controller.app.controllers_have_started.wait()
log.debug(f'{request.path} resuming')
result = await implementation(**args) result = await implementation(**args)
resp = web.json_response( resp = web.json_response(
serializer.serialize(def_ret_ann, result), serializer.serialize(def_ret_ann, result),

View File

@ -277,6 +277,10 @@ class API:
def POST(config: Payload[list]): ... def POST(config: Payload[list]): ...
class dry_run_wait_probe:
"""This endpoint only works in dry-run mode."""
def POST() -> None: ...
class reset: class reset:
def POST() -> StorageResponse: ... def POST() -> StorageResponse: ...
@ -302,20 +306,24 @@ class API:
class reset: class reset:
def POST() -> StorageResponseV2: ... def POST() -> StorageResponseV2: ...
class ensure_transaction:
"""This call will ensure that a transaction is initiated.
During a transaction, storage probing runs are not permitted to
reset the partitioning configuration.
A transaction will also be initiated by any v2_storage POST
request that modifies the partitioning configuration (e.g.,
add_partition, edit_partition, ...) but ensure_transaction can
be called early if desired. """
def POST() -> None: ...
class reformat_disk: class reformat_disk:
def POST(data: Payload[ReformatDisk]) \ def POST(data: Payload[ReformatDisk]) \
-> StorageResponseV2: ... -> StorageResponseV2: ...
class potential_boot_disks:
"""Obtain the list of disks which can be made bootable.
This list can be empty if there are no disks or if none of them
are plausible sources of a boot disk."""
def GET() -> List[str]: ...
class add_boot_partition: class add_boot_partition:
"""Mark a given disk as bootable, which may cause a partition """Mark a given disk as bootable, which may cause a partition
to be added to the disk. It is an error to call this for a to be added to the disk. It is an error to call this for a
disk not in the list from potential_boot_disks.""" disk for which can_be_boot_device is False."""
def POST(disk_id: str) -> StorageResponseV2: ... def POST(disk_id: str) -> StorageResponseV2: ...
class add_partition: class add_partition:

View File

@ -18,9 +18,11 @@ import fcntl
import json import json
import logging import logging
import os import os
import re
import sys import sys
import time import time
import traceback import traceback
from typing import Iterable, Set
import apport import apport
import apport.crashdb import apport.crashdb
@ -138,6 +140,10 @@ class ErrorReport(metaclass=urwid.MetaSignals):
if not self.reporter.dry_run: if not self.reporter.dry_run:
self.pr.add_hooks_info(None) self.pr.add_hooks_info(None)
apport.hookutils.attach_hardware(self.pr) apport.hookutils.attach_hardware(self.pr)
self.pr['Syslog'] = apport.hookutils.recent_syslog(re.compile('.'))
snap_name = os.environ.get('SNAP_NAME', '')
if snap_name != '':
self.add_tags([snap_name])
# Because apport-cli will in general be run on a different # Because apport-cli will in general be run on a different
# machine, we make some slightly obscure alterations to the report # machine, we make some slightly obscure alterations to the report
# to make this go better. # to make this go better.
@ -327,6 +333,20 @@ class ErrorReport(metaclass=urwid.MetaSignals):
oops_id=self.oops_id, oops_id=self.oops_id,
) )
# with core24 these tag methods can be dropped for equivalent methods
# that will be on the report object
def get_tags(self) -> Set[str]:
"""Return the set of tags."""
if "Tags" not in self.pr:
return set()
return set(self.pr["Tags"].split(" "))
def add_tags(self, tags: Iterable[str]) -> None:
"""Add tags to the report. Duplicates are dropped."""
current_tags = self.get_tags()
new_tags = current_tags.union(tags)
self.pr["Tags"] = " ".join(sorted(new_tags))
class ErrorReporter(object): class ErrorReporter(object):

View File

@ -149,6 +149,9 @@ def find_disk_gaps_v2(device, info=None):
for part in parts + [None]: for part in parts + [None]:
if part is None: if part is None:
gap_end = ad(device.size - info.min_end_offset) gap_end = ad(device.size - info.min_end_offset)
else:
if part.is_logical:
gap_end = ad(part.offset - info.ebr_space)
else: else:
gap_end = ad(part.offset) gap_end = ad(part.offset)

View File

@ -306,6 +306,7 @@ def _for_client_disk(disk, *, min_size=0):
usage_labels=usage_labels(disk), usage_labels=usage_labels(disk),
partitions=[for_client(p) for p in gaps.parts_and_gaps(disk)], partitions=[for_client(p) for p in gaps.parts_and_gaps(disk)],
boot_device=boot.is_boot_device(disk), boot_device=boot.is_boot_device(disk),
can_be_boot_device=boot.can_be_boot_device(disk),
ok_for_guided=disk.size >= min_size, ok_for_guided=disk.size >= min_size,
model=getattr(disk, 'model', None), model=getattr(disk, 'model', None),
vendor=getattr(disk, 'vendor', None)) vendor=getattr(disk, 'vendor', None))

View File

@ -68,8 +68,9 @@ class FilesystemManipulator:
volume.flag = "" volume.flag = ""
if spec.get('fstype') == "swap": if spec.get('fstype') == "swap":
self.model.add_mount(fs, "") self.model.add_mount(fs, "")
if spec.get('fstype') is None and spec.get('use_swap'): elif spec.get('fstype') is None and spec.get('use_swap'):
self.model.add_mount(fs, "") self.model.add_mount(fs, "")
else:
self.create_mount(fs, spec) self.create_mount(fs, spec)
return fs return fs

View File

@ -149,3 +149,21 @@ def calculate_suggested_install_min(source_min: int,
room_for_swap = swap.suggested_swapsize() room_for_swap = swap.suggested_swapsize()
total = source_min + room_for_boot + room_to_grow + room_for_swap total = source_min + room_for_boot + room_to_grow + room_for_swap
return align_up(total, part_align) return align_up(total, part_align)
# Scale the usage of the vg to leave room for snapshots and such. We should
# use more of a smaller disk to avoid the user running into out of space errors
# earlier than they probably expect to.
def scaled_rootfs_size(available: int):
if available < 10 * (1 << 30):
# Use all of a small (<10G) disk.
return available
elif available < 20 * (1 << 30):
# Use 10G of a smallish (<20G) disk.
return 10 * (1 << 30)
elif available < 200 * (1 << 30):
# Use half of a larger (<200G) disk.
return available // 2
else:
# Use at most 100G of a large disk.
return 100 * (1 << 30)

View File

@ -333,6 +333,32 @@ class TestDiskGaps(unittest.TestCase):
gaps.Gap(d, 50, 50, False), gaps.Gap(d, 50, 50, False),
]) ])
def test_gap_before_primary(self):
# 0----10---20---30---40---50---60---70---80---90---100
# [ g1 ][ p1 (primary) ]
info = PartitionAlignmentData(
part_align=5, min_gap_size=1, min_start_offset=0, min_end_offset=0,
ebr_space=1, primary_part_limit=10)
m, d = make_model_and_disk(size=100, ptable='dos')
p1 = make_partition(m, d, offset=50, size=50)
self.assertEqual(
gaps.find_disk_gaps_v2(d, info),
[gaps.Gap(d, 0, 50, False), p1])
def test_gap_in_extended_before_logical(self):
# 0----10---20---30---40---50---60---70---80---90---100
# [ p1 (extended) ]
# [ g1 ] [ p5 (logical) ]
info = PartitionAlignmentData(
part_align=5, min_gap_size=1, min_start_offset=0, min_end_offset=0,
ebr_space=1, primary_part_limit=10)
m, d = make_model_and_disk(size=100, ptable='dos')
p1 = make_partition(m, d, offset=0, size=100, flag='extended')
p5 = make_partition(m, d, offset=50, size=50, flag='logical')
self.assertEqual(
gaps.find_disk_gaps_v2(d, info),
[p1, gaps.Gap(d, 5, 40, True), p5])
def test_unusable_gap_primaries(self): def test_unusable_gap_primaries(self):
info = PartitionAlignmentData( info = PartitionAlignmentData(
part_align=10, min_gap_size=1, min_start_offset=0, part_align=10, min_gap_size=1, min_start_offset=0,

View File

@ -0,0 +1,32 @@
# Copyright 2023 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 unittest
from subiquity.common.types import SizingPolicy
class TestSizingPolicy(unittest.TestCase):
def test_all(self):
actual = SizingPolicy.from_string('all')
self.assertEqual(SizingPolicy.ALL, actual)
def test_scaled_size(self):
actual = SizingPolicy.from_string('scaled')
self.assertEqual(SizingPolicy.SCALED, actual)
def test_default(self):
actual = SizingPolicy.from_string(None)
self.assertEqual(SizingPolicy.SCALED, actual)

View File

@ -158,6 +158,12 @@ AnyStep = Union[StepPressKey, StepKeyPresent, StepResult]
@attr.s(auto_attribs=True) @attr.s(auto_attribs=True)
class KeyboardSetting: class KeyboardSetting:
# This data structure represents a subset of the XKB options.
# As explained in the XKB configuration guide, XkbLayout and
# XkbVariant can hold multiple comma-separated values.
# http://www.xfree86.org/current/XKB-Config2.html#4
# Ideally, we would internally represent a keyboard setting as a
# toggle + a list of [layout, variant].
layout: str layout: str
variant: str = '' variant: str = ''
toggle: Optional[str] = None toggle: Optional[str] = None
@ -311,6 +317,7 @@ class Disk:
preserve: bool preserve: bool
path: Optional[str] path: Optional[str]
boot_device: bool boot_device: bool
can_be_boot_device: bool
model: Optional[str] = None model: Optional[str] = None
vendor: Optional[str] = None vendor: Optional[str] = None
@ -386,6 +393,19 @@ class StorageResponseV2:
install_minimum_size: Optional[int] = None install_minimum_size: Optional[int] = None
class SizingPolicy(enum.Enum):
SCALED = enum.auto()
ALL = enum.auto()
@classmethod
def from_string(cls, value):
if value is None or value == 'scaled':
return cls.SCALED
if value == 'all':
return cls.ALL
raise Exception(f'Unknown SizingPolicy value {value}')
@attr.s(auto_attribs=True) @attr.s(auto_attribs=True)
class GuidedResizeValues: class GuidedResizeValues:
install_max: int install_max: int
@ -436,6 +456,8 @@ class GuidedChoiceV2:
target: GuidedStorageTarget target: GuidedStorageTarget
use_lvm: bool = False use_lvm: bool = False
password: Optional[str] = attr.ib(default=None, repr=False) password: Optional[str] = attr.ib(default=None, repr=False)
sizing_policy: Optional[SizingPolicy] = \
attr.ib(default=SizingPolicy.SCALED)
@staticmethod @staticmethod
def from_guided_choice(choice: GuidedChoice): def from_guided_choice(choice: GuidedChoice):
@ -443,6 +465,7 @@ class GuidedChoiceV2:
target=GuidedStorageTargetReformat(disk_id=choice.disk_id), target=GuidedStorageTargetReformat(disk_id=choice.disk_id),
use_lvm=choice.use_lvm, use_lvm=choice.use_lvm,
password=choice.password, password=choice.password,
sizing_policy=SizingPolicy.SCALED,
) )

View File

@ -820,16 +820,22 @@ class Raid(_Device):
wipe = attr.ib(default=None) wipe = attr.ib(default=None)
ptable = attributes.ptable() ptable = attributes.ptable()
metadata = attr.ib(default=None) metadata = attr.ib(default=None)
path = attr.ib(default=None) _path = attr.ib(default=None)
container = attributes.ref(backlink="_subvolumes", default=None) # Raid container = attributes.ref(backlink="_subvolumes", default=None) # Raid
_subvolumes = attributes.backlink(default=attr.Factory(list)) _subvolumes = attributes.backlink(default=attr.Factory(list))
@property @property
def path(self): def path(self):
if self._path is not None:
return self._path
# This is just here to make for_client(raid-with-partitions) work. It # This is just here to make for_client(raid-with-partitions) work. It
# might not be very accurate. # might not be very accurate.
return '/dev/md/' + self.name return '/dev/md/' + self.name
@path.setter
def path(self, value):
self._path = value
@property @property
def size(self): def size(self):
if self.preserve and self._m._probe_data: if self.preserve and self._m._probe_data:

View File

@ -43,6 +43,15 @@ BACKSPACE="guess"
""" """
class InconsistentMultiLayoutError(ValueError):
""" Exception to raise when a multi layout has a different number of
layouts and variants. """
def __init__(self, layouts: str, variants: str) -> None:
super().__init__(
f'inconsistent multi-layout: layouts="{layouts}"'
f' variants="{variants}"')
def from_config_file(config_file): def from_config_file(config_file):
with open(config_file) as fp: with open(config_file) as fp:
content = fp.read() content = fp.read()
@ -91,13 +100,21 @@ class KeyboardModel:
self._setting = value self._setting = value
def validate_setting(self, setting: KeyboardSetting) -> None: def validate_setting(self, setting: KeyboardSetting) -> None:
kbd_layout = self.keyboard_list.layout_map.get(setting.layout) layout_tokens = setting.layout.split(",")
variant_tokens = setting.variant.split(",")
if len(layout_tokens) != len(variant_tokens):
raise InconsistentMultiLayoutError(
layouts=setting.layout, variants=setting.variant)
for layout, variant in zip(layout_tokens, variant_tokens):
kbd_layout = self.keyboard_list.layout_map.get(layout)
if kbd_layout is None: if kbd_layout is None:
raise ValueError(f'Unknown keyboard layout "{setting.layout}"') raise ValueError(f'Unknown keyboard layout "{layout}"')
if not any(variant.code == setting.variant if not any(kbd_variant.code == variant
for variant in kbd_layout.variants): for kbd_variant in kbd_layout.variants):
raise ValueError(f'Unknown keyboard variant "{setting.variant}" ' raise ValueError(f'Unknown keyboard variant "{variant}" '
f'for layout "{setting.layout}"') f'for layout "{layout}"')
def render_config_file(self): def render_config_file(self):
options = "" options = ""

View File

@ -37,22 +37,19 @@ class LocaleModel:
def switch_language(self, code): def switch_language(self, code):
self.selected_language = code self.selected_language = code
async def gen_localedef(self) -> None: async def localectl_set_locale(self) -> None:
language, charmap = locale.normalize(self.selected_language).split(".")
cmd = [ cmd = [
"localedef", 'localectl',
"-f", charmap, 'set-locale',
"-i", language, locale.normalize(self.selected_language)
"--",
f"{language}.{charmap}",
] ]
await arun_command(cmd, check=True) await arun_command(cmd, check=True)
async def try_gen_localedef(self) -> None: async def try_localectl_set_locale(self) -> None:
try: try:
await self.gen_localedef() await self.localectl_set_locale()
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
log.warning("Could not generate locale: %r", exc) log.warning("Could not localectl set-locale: %r", exc)
def __repr__(self): def __repr__(self):
return "<Selected: {}>".format(self.selected_language) return "<Selected: {}>".format(self.selected_language)

View File

@ -89,7 +89,9 @@ from curtin.commands.apt_config import (
get_arch_mirrorconfig, get_arch_mirrorconfig,
get_mirror, get_mirror,
PORTS_ARCHES, PORTS_ARCHES,
PORTS_MIRRORS,
PRIMARY_ARCHES, PRIMARY_ARCHES,
PRIMARY_ARCH_MIRRORS,
) )
from curtin.config import merge_config from curtin.config import merge_config
@ -100,8 +102,8 @@ except ImportError:
log = logging.getLogger('subiquity.models.mirror') log = logging.getLogger('subiquity.models.mirror')
DEFAULT_SUPPORTED_ARCHES_URI = "http://archive.ubuntu.com/ubuntu" DEFAULT_SUPPORTED_ARCHES_URI = PRIMARY_ARCH_MIRRORS["PRIMARY"]
DEFAULT_PORTS_ARCHES_URI = "http://ports.ubuntu.com/ubuntu-ports" DEFAULT_PORTS_ARCHES_URI = PORTS_MIRRORS["PRIMARY"]
LEGACY_DEFAULT_PRIMARY_SECTION = [ LEGACY_DEFAULT_PRIMARY_SECTION = [
{ {
@ -113,6 +115,17 @@ LEGACY_DEFAULT_PRIMARY_SECTION = [
}, },
] ]
DEFAULT_SECURITY_SECTION = [
{
"arches": PRIMARY_ARCHES,
"uri": PRIMARY_ARCH_MIRRORS["SECURITY"],
},
{
"arches": PORTS_ARCHES,
"uri": PORTS_MIRRORS["SECURITY"],
},
]
DEFAULT = { DEFAULT = {
"preserve_sources_list": False, "preserve_sources_list": False,
} }
@ -311,6 +324,10 @@ class MirrorModel(object):
config = copy.deepcopy(self.config) config = copy.deepcopy(self.config)
config["disable_components"] = sorted(self.disabled_components) config["disable_components"] = sorted(self.disabled_components)
if "security" not in config:
config["security"] = DEFAULT_SECURITY_SECTION
return config return config
def _get_apt_config_using_candidate( def _get_apt_config_using_candidate(
@ -321,7 +338,15 @@ class MirrorModel(object):
def get_apt_config_staged(self) -> Dict[str, Any]: def get_apt_config_staged(self) -> Dict[str, Any]:
assert self.primary_staged is not None assert self.primary_staged is not None
return self._get_apt_config_using_candidate(self.primary_staged) config = self._get_apt_config_using_candidate(self.primary_staged)
# For mirror testing, we disable the -security suite - so that we only
# test the primary mirror, not the security archive.
if "disable_suites" not in config:
config["disable_suites"]: List[str] = []
if "security" not in config["disable_suites"]:
config["disable_suites"].append("security")
return config
def get_apt_config_elected(self) -> Dict[str, Any]: def get_apt_config_elected(self) -> Dict[str, Any]:
assert self.primary_elected is not None assert self.primary_elected is not None

View File

@ -14,8 +14,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging import logging
import subprocess
from subiquitycore.models.network import NetworkModel as CoreNetworkModel from subiquitycore.models.network import NetworkModel as CoreNetworkModel
from subiquitycore.utils import arun_command
log = logging.getLogger('subiquity.models.network') log = logging.getLogger('subiquity.models.network')
@ -72,3 +74,16 @@ class NetworkModel(CoreNetworkModel):
return ['wpasupplicant'] return ['wpasupplicant']
else: else:
return [] return []
async def is_nm_enabled(self):
try:
cp = await arun_command(("nmcli", "networking"), check=True)
except subprocess.CalledProcessError as exc:
log.warning("failed to run nmcli networking,"
" considering NetworkManager disabled.")
log.debug("stderr: %s", exc.stderr)
return False
except FileNotFoundError:
return False
return cp.stdout == "enabled\n"

View File

@ -18,7 +18,10 @@ from subiquitycore.tests.parameterized import parameterized
from subiquitycore.tests import SubiTestCase from subiquitycore.tests import SubiTestCase
from subiquity.common.types import KeyboardSetting from subiquity.common.types import KeyboardSetting
from subiquity.models.keyboard import KeyboardModel from subiquity.models.keyboard import (
InconsistentMultiLayoutError,
KeyboardModel,
)
class TestKeyboardModel(SubiTestCase): class TestKeyboardModel(SubiTestCase):
@ -45,6 +48,25 @@ class TestKeyboardModel(SubiTestCase):
self.model.setting = val self.model.setting = val
self.assertEqual(initial, self.model.setting) self.assertEqual(initial, self.model.setting)
def testMultiLayout(self):
val = KeyboardSetting(layout='us,ara', variant=',')
self.model.setting = val
self.assertEqual(self.model.setting, val)
def testInconsistentMultiLayout(self):
initial = self.model.setting
val = KeyboardSetting(layout='us,ara', variant='')
with self.assertRaises(InconsistentMultiLayoutError):
self.model.setting = val
self.assertEqual(self.model.setting, initial)
def testInvalidMultiLayout(self):
initial = self.model.setting
val = KeyboardSetting(layout='us,ara', variant='zz,')
with self.assertRaises(ValueError):
self.model.setting = val
self.assertEqual(self.model.setting, initial)
@parameterized.expand([ @parameterized.expand([
['ast_ES.UTF-8', 'es', 'ast'], ['ast_ES.UTF-8', 'es', 'ast'],
['de_DE.UTF-8', 'de', ''], ['de_DE.UTF-8', 'de', ''],

View File

@ -28,31 +28,29 @@ class TestLocaleModel(unittest.IsolatedAsyncioTestCase):
self.model.switch_language("fr_FR.UTF-8") self.model.switch_language("fr_FR.UTF-8")
self.assertEqual(self.model.selected_language, "fr_FR.UTF-8") self.assertEqual(self.model.selected_language, "fr_FR.UTF-8")
async def test_gen_localedef(self): async def test_localectl_set_locale(self):
expected_cmd = [ expected_cmd = [
"localedef", "localectl",
"-f", "UTF-8", "set-locale",
"-i", "fr_FR",
"--",
"fr_FR.UTF-8", "fr_FR.UTF-8",
] ]
self.model.selected_language = "fr_FR.UTF-8" self.model.selected_language = "fr_FR.UTF-8"
with mock.patch("subiquity.models.locale.arun_command") as arun_cmd: with mock.patch("subiquity.models.locale.arun_command") as arun_cmd:
await self.model.gen_localedef() await self.model.localectl_set_locale()
arun_cmd.assert_called_once_with(expected_cmd, check=True) arun_cmd.assert_called_once_with(expected_cmd, check=True)
self.model.selected_language = "fr_FR" self.model.selected_language = "fr_FR"
with mock.patch("subiquity.models.locale.arun_command") as arun_cmd: with mock.patch("subiquity.models.locale.arun_command") as arun_cmd:
# Currently, the default for fr_FR is fr_FR.ISO8859-1 # Currently, the default for fr_FR is fr_FR.ISO8859-1
with mock.patch("subiquity.models.locale.locale.normalize", with mock.patch("subiquity.models.locale.locale.normalize",
return_value="fr_FR.UTF-8"): return_value="fr_FR.UTF-8"):
await self.model.gen_localedef() await self.model.localectl_set_locale()
arun_cmd.assert_called_once_with(expected_cmd, check=True) arun_cmd.assert_called_once_with(expected_cmd, check=True)
async def test_try_gen_localedef(self): async def test_try_localectl_set_locale(self):
self.model.selected_language = "fr_FR.UTF-8" self.model.selected_language = "fr_FR.UTF-8"
exc = subprocess.CalledProcessError(returncode=1, cmd=["localedef"]) exc = subprocess.CalledProcessError(returncode=1, cmd=["localedef"])
with mock.patch("subiquity.models.locale.arun_command", with mock.patch("subiquity.models.locale.arun_command",
side_effect=exc): side_effect=exc):
await self.model.try_gen_localedef() await self.model.try_localectl_set_locale()
with mock.patch("subiquity.models.locale.arun_command"): with mock.patch("subiquity.models.locale.arun_command"):
await self.model.try_gen_localedef() await self.model.try_localectl_set_locale()

View File

@ -19,6 +19,7 @@ from unittest import mock
from subiquity.models.mirror import ( from subiquity.models.mirror import (
countrify_uri, countrify_uri,
DEFAULT_SECURITY_SECTION,
LEGACY_DEFAULT_PRIMARY_SECTION, LEGACY_DEFAULT_PRIMARY_SECTION,
MirrorModel, MirrorModel,
MirrorSelectionFallback, MirrorSelectionFallback,
@ -150,7 +151,7 @@ class TestMirrorModel(unittest.TestCase):
self.assertIn( self.assertIn(
country_mirror_candidate.uri, country_mirror_candidate.uri,
[ [
"http://CC.archive.ubuntu.com/ubuntu", "http://CC.archive.ubuntu.com/ubuntu/",
"http://CC.ports.ubuntu.com/ubuntu-ports", "http://CC.ports.ubuntu.com/ubuntu-ports",
]) ])
@ -288,3 +289,96 @@ class TestMirrorModel(unittest.TestCase):
return_value=iter([PrimaryEntry(parent=self.model)])) return_value=iter([PrimaryEntry(parent=self.model)]))
with country_mirror_candidates: with country_mirror_candidates:
self.assertTrue(self.model.wants_geoip()) self.assertTrue(self.model.wants_geoip())
def test_get_apt_config_staged_default_config(self):
self.model.legacy_primary = False
self.model.primary_candidates = [
PrimaryEntry(
uri="http://mirror.local/ubuntu",
arches=None,
parent=self.model
),
]
self.model.primary_candidates[0].stage()
config = self.model.get_apt_config_staged()
self.assertEqual(
config["primary"],
[
{
"uri": "http://mirror.local/ubuntu",
"arches": ["default"],
}
],
)
self.assertEqual(
set(config["disable_components"]),
set(self.model.disabled_components)
)
self.assertEqual(set(config["disable_suites"]), {"security"})
self.assertEqual(config["security"], DEFAULT_SECURITY_SECTION)
def test_get_apt_config_staged_with_config(self):
self.model.legacy_primary = False
self.model.primary_candidates = [
PrimaryEntry(
uri="http://mirror.local/ubuntu",
arches=None,
parent=self.model
),
]
self.model.primary_candidates[0].stage()
security_config = [
{
"arches": ["default"],
"uri": "http://security.ubuntu.com/ubuntu",
},
]
self.model.config = {
"disable_suites": ["updates"],
"security": security_config,
}
config = self.model.get_apt_config_staged()
self.assertEqual(
config["primary"],
[
{
"uri": "http://mirror.local/ubuntu",
"arches": ["default"],
}
],
)
self.assertEqual(
set(config["disable_components"]),
set(self.model.disabled_components)
)
self.assertEqual(
set(config["disable_suites"]),
{"security", "updates"}
)
self.assertEqual(config["security"], security_config)
def test_get_apt_config_elected_default_config(self):
self.model.legacy_primary = False
self.model.primary_candidates = [
PrimaryEntry(
uri="http://mirror.local/ubuntu",
arches=None,
parent=self.model
),
]
self.model.primary_candidates[0].elect()
config = self.model.get_apt_config_elected()
self.assertEqual(
config["primary"],
[
{
"uri": "http://mirror.local/ubuntu",
"arches": ["default"],
}
],
)
self.assertEqual(
set(config["disable_components"]),
set(self.model.disabled_components)
)
self.assertEqual(config["security"], DEFAULT_SECURITY_SECTION)

View File

@ -0,0 +1,47 @@
# Copyright 2023 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 subprocess
import unittest
from unittest import mock
from subiquity.models.network import (
NetworkModel,
)
class TestNetworkModel(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.model = NetworkModel()
async def test_is_nm_enabled(self):
with mock.patch("subiquity.models.network.arun_command") as arun:
arun.return_value = subprocess.CompletedProcess([], 0)
arun.return_value.stdout = "enabled\n"
self.assertTrue(await self.model.is_nm_enabled())
with mock.patch("subiquity.models.network.arun_command") as arun:
arun.return_value = subprocess.CompletedProcess([], 0)
arun.return_value.stdout = "disabled\n"
self.assertFalse(await self.model.is_nm_enabled())
with mock.patch("subiquity.models.network.arun_command") as arun:
arun.side_effect = FileNotFoundError
self.assertFalse(await self.model.is_nm_enabled())
with mock.patch("subiquity.models.network.arun_command") as arun:
arun.side_effect = subprocess.CalledProcessError(
1, [], None, "error")
self.assertFalse(await self.model.is_nm_enabled())

View File

@ -16,6 +16,7 @@
import asyncio import asyncio
from contextlib import contextmanager from contextlib import contextmanager
import logging import logging
import os
from socket import gethostname from socket import gethostname
from subprocess import CalledProcessError from subprocess import CalledProcessError
from subiquitycore.utils import arun_command, run_command from subiquitycore.utils import arun_command, run_command
@ -29,18 +30,28 @@ log = logging.getLogger('subiquity.server.ad_joiner')
@contextmanager @contextmanager
def hostname_context(hostname: str): def joining_context(hostname: str, root_dir: str):
""" Temporarily adjusts the host name to [hostname] and restores it """ Temporarily adjusts the host name to [hostname] and bind-mounts
back in the end of the caller scope. """ interesting system directories in preparation for running realm
in target's [root_dir], undoing it all on exit. """
hostname_current = gethostname() hostname_current = gethostname()
hostname_process = run_command(['hostname', hostname]) binds = ("/proc", "/sys", "/dev", "/run")
try: try:
hostname_process = run_command(['hostname', hostname])
for bind in binds:
bound_dir = os.path.join(root_dir, bind[1:])
if bound_dir != bind:
run_command(["mount", "--bind", bind, bound_dir])
yield hostname_process yield hostname_process
finally: finally:
# Restoring the live session hostname. # Restoring the live session hostname.
hostname_process = run_command(['hostname', hostname_current]) hostname_process = run_command(['hostname', hostname_current])
if hostname_process.returncode: if hostname_process.returncode:
log.info("Failed to restore live session hostname") log.info("Failed to restore live session hostname")
for bind in reversed(binds):
bound_dir = os.path.join(root_dir, bind[1:])
if bound_dir != bind:
run_command(["umount", "-f", bound_dir])
class AdJoinStrategy(): class AdJoinStrategy():
@ -54,14 +65,14 @@ class AdJoinStrategy():
-> AdJoinResult: -> AdJoinResult:
""" This method changes the hostname and perform a real AD join, thus """ This method changes the hostname and perform a real AD join, thus
should only run in a live session. """ should only run in a live session. """
root_dir = self.app.base_model.target
# Set hostname for AD to determine FQDN (no FQDN option in realm join, # Set hostname for AD to determine FQDN (no FQDN option in realm join,
# only adcli, which only understands the live system, but not chroot) # only adcli, which only understands the live system, but not chroot)
with hostname_context(hostname) as host_process: with joining_context(hostname, root_dir) as host_process:
if host_process.returncode: if host_process.returncode:
log.info("Failed to set live session hostname for adcli") log.info("Failed to set live session hostname for adcli")
return AdJoinResult.JOIN_ERROR return AdJoinResult.JOIN_ERROR
root_dir = self.app.root
cp = await arun_command([self.realm, "join", "--install", root_dir, cp = await arun_command([self.realm, "join", "--install", root_dir,
"--user", info.admin_name, "--user", info.admin_name,
"--computer-name", hostname, "--computer-name", hostname,

View File

@ -33,7 +33,7 @@ from curtin.config import merge_config
from subiquitycore.file_util import write_file, generate_config_yaml from subiquitycore.file_util import write_file, generate_config_yaml
from subiquitycore.lsb_release import lsb_release from subiquitycore.lsb_release import lsb_release
from subiquitycore.utils import astart_command from subiquitycore.utils import astart_command, orig_environ
from subiquity.server.curtin import run_curtin_command from subiquity.server.curtin import run_curtin_command
from subiquity.server.mounter import ( from subiquity.server.mounter import (
@ -183,7 +183,7 @@ class AptConfigurer:
for target in get_index_targets(): for target in get_index_targets():
apt_cmd.append(f"-o{target}::DefaultEnabled=false") apt_cmd.append(f"-o{target}::DefaultEnabled=false")
env = os.environ.copy() env = orig_environ(None)
env["LANG"] = self.app.base_model.locale.selected_language env["LANG"] = self.app.base_model.locale.selected_language
with tempfile.NamedTemporaryFile(mode="w+") as config_file: with tempfile.NamedTemporaryFile(mode="w+") as config_file:
env["APT_CONFIG"] = config_file.name env["APT_CONFIG"] = config_file.name

View File

@ -128,7 +128,11 @@ class AdController(SubiquityController):
def interactive(self): def interactive(self):
# Since we don't accept the domain admin password in the autoinstall # Since we don't accept the domain admin password in the autoinstall
# file, this cannot be non-interactive. # file, this cannot be non-interactive.
return True
# HACK: the interactive behavior is causing some autoinstalls with
# desktop to block.
# return True
return False
def __init__(self, app): def __init__(self, app):
super().__init__(app) super().__init__(app)

View File

@ -22,7 +22,7 @@ import os
import pathlib import pathlib
import select import select
import time import time
from typing import Dict, List, Optional from typing import Any, Dict, List, Optional
from curtin.storage_config import ptable_uuid_to_flag_entry from curtin.storage_config import ptable_uuid_to_flag_entry
@ -70,6 +70,7 @@ from subiquity.common.types import (
ProbeStatus, ProbeStatus,
ReformatDisk, ReformatDisk,
StorageEncryptionSupport, StorageEncryptionSupport,
SizingPolicy,
StorageResponse, StorageResponse,
StorageResponseV2, StorageResponseV2,
StorageSafety, StorageSafety,
@ -153,6 +154,10 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
self._role_to_device: Dict[str: _Device] = {} self._role_to_device: Dict[str: _Device] = {}
self._device_to_structure: Dict[_Device: snapdapi.OnVolume] = {} self._device_to_structure: Dict[_Device: snapdapi.OnVolume] = {}
self.use_tpm: bool = False self.use_tpm: bool = False
self.locked_probe_data = False
# If probe data come in while we are doing partitioning, store it in
# this variable. It will be picked up on next reset will pick it up.
self.queued_probe_data: Optional[Dict[str, Any]] = None
def is_core_boot_classic(self): def is_core_boot_classic(self):
return self._system is not None return self._system is not None
@ -270,7 +275,7 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
spec = dict(fstype="ext4", mount="/") spec = dict(fstype="ext4", mount="/")
self.create_partition(device=gap.device, gap=gap, spec=spec) self.create_partition(device=gap.device, gap=gap, spec=spec)
def guided_lvm(self, gap, lvm_options=None): def guided_lvm(self, gap, choice: GuidedChoiceV2):
device = gap.device device = gap.device
part_align = device.alignment_data().part_align part_align = device.alignment_data().part_align
bootfs_size = align_up(sizes.get_bootfs_size(gap.size), part_align) bootfs_size = align_up(sizes.get_bootfs_size(gap.size), part_align)
@ -285,26 +290,17 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
i += 1 i += 1
vg_name = 'ubuntu-vg-{}'.format(i) vg_name = 'ubuntu-vg-{}'.format(i)
spec = dict(name=vg_name, devices=set([part])) spec = dict(name=vg_name, devices=set([part]))
if lvm_options and lvm_options['encrypt']: if choice.password is not None:
spec['passphrase'] = lvm_options['luks_options']['passphrase'] spec['passphrase'] = choice.password
vg = self.create_volgroup(spec) vg = self.create_volgroup(spec)
# There's no point using LVM and unconditionally filling the if choice.sizing_policy == SizingPolicy.SCALED:
# VG with a single LV, but we should use more of a smaller lv_size = sizes.scaled_rootfs_size(vg.size)
# disk to avoid the user running into out of space errors
# earlier than they probably expect to.
if vg.size < 10 * (1 << 30):
# Use all of a small (<10G) disk.
lv_size = vg.size
elif vg.size < 20 * (1 << 30):
# Use 10G of a smallish (<20G) disk.
lv_size = 10 * (1 << 30)
elif vg.size < 200 * (1 << 30):
# Use half of a larger (<200G) disk.
lv_size = vg.size // 2
else:
# Use at most 100G of a large disk.
lv_size = 100 * (1 << 30)
lv_size = align_down(lv_size, LVM_CHUNK_SIZE) lv_size = align_down(lv_size, LVM_CHUNK_SIZE)
elif choice.sizing_policy == SizingPolicy.ALL:
lv_size = vg.size
else:
raise Exception(f'Unhandled size policy {choice.sizing_policy}')
log.debug(f'lv_size {lv_size} for {choice.sizing_policy}')
self.create_logical_volume( self.create_logical_volume(
vg=vg, spec=dict( vg=vg, spec=dict(
size=lv_size, size=lv_size,
@ -354,17 +350,6 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
raise Exception(f'gap not found after resize, pgs={pgs}') raise Exception(f'gap not found after resize, pgs={pgs}')
return gap return gap
def build_lvm_options(self, passphrase):
if passphrase is None:
return None
else:
return {
'encrypt': True,
'luks_options': {
'passphrase': passphrase,
},
}
def guided(self, choice: GuidedChoiceV2): def guided(self, choice: GuidedChoiceV2):
self.model.guided_configuration = choice self.model.guided_configuration = choice
@ -378,8 +363,7 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
raise Exception('failed to locate gap after adding boot') raise Exception('failed to locate gap after adding boot')
if choice.use_lvm: if choice.use_lvm:
lvm_options = self.build_lvm_options(choice.password) self.guided_lvm(gap, choice)
self.guided_lvm(gap, lvm_options=lvm_options)
else: else:
self.guided_direct(gap) self.guided_direct(gap)
@ -664,9 +648,22 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
async def v2_reset_POST(self) -> StorageResponseV2: async def v2_reset_POST(self) -> StorageResponseV2:
log.info("Resetting Filesystem model") log.info("Resetting Filesystem model")
# From the API standpoint, it seems sound to set locked_probe_data back
# to False after a reset. But in practise, v2_reset_POST can be called
# during manual partitioning ; and we don't want to reenable automatic
# loading of probe data. Going forward, this could be controlled by an
# optional parameter maybe?
if self.queued_probe_data is not None:
log.debug("using newly obtained probe data")
self.model.load_probe_data(self.queued_probe_data)
self.queued_probe_data = None
else:
self.model.reset() self.model.reset()
return await self.v2_GET() return await self.v2_GET()
async def v2_ensure_transaction_POST(self) -> None:
self.locked_probe_data = True
async def v2_guided_GET(self, wait: bool = False) \ async def v2_guided_GET(self, wait: bool = False) \
-> GuidedStorageResponseV2: -> GuidedStorageResponseV2:
"""Acquire a list of possible guided storage configuration scenarios. """Acquire a list of possible guided storage configuration scenarios.
@ -722,21 +719,19 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
async def v2_guided_POST(self, data: GuidedChoiceV2) \ async def v2_guided_POST(self, data: GuidedChoiceV2) \
-> GuidedStorageResponseV2: -> GuidedStorageResponseV2:
log.debug(data) log.debug(data)
self.locked_probe_data = True
self.guided(data) self.guided(data)
return await self.v2_guided_GET() return await self.v2_guided_GET()
async def v2_reformat_disk_POST(self, data: ReformatDisk) \ async def v2_reformat_disk_POST(self, data: ReformatDisk) \
-> StorageResponseV2: -> StorageResponseV2:
self.locked_probe_data = True
self.reformat(self.model._one(id=data.disk_id), data.ptable) self.reformat(self.model._one(id=data.disk_id), data.ptable)
return await self.v2_GET() return await self.v2_GET()
async def v2_potential_boot_disks_GET(self) -> List[str]:
disks = self.potential_boot_disks(check_boot=True,
with_reformatting=False)
return [disk.id for disk in disks]
async def v2_add_boot_partition_POST(self, disk_id: str) \ async def v2_add_boot_partition_POST(self, disk_id: str) \
-> StorageResponseV2: -> StorageResponseV2:
self.locked_probe_data = True
disk = self.model._one(id=disk_id) disk = self.model._one(id=disk_id)
if boot.is_boot_device(disk): if boot.is_boot_device(disk):
raise ValueError('device already has bootloader partition') raise ValueError('device already has bootloader partition')
@ -748,6 +743,7 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
async def v2_add_partition_POST(self, data: AddPartitionV2) \ async def v2_add_partition_POST(self, data: AddPartitionV2) \
-> StorageResponseV2: -> StorageResponseV2:
log.debug(data) log.debug(data)
self.locked_probe_data = True
if data.partition.boot is not None: if data.partition.boot is not None:
raise ValueError('add_partition does not support changing boot') raise ValueError('add_partition does not support changing boot')
disk = self.model._one(id=data.disk_id) disk = self.model._one(id=data.disk_id)
@ -769,6 +765,7 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
async def v2_delete_partition_POST(self, data: ModifyPartitionV2) \ async def v2_delete_partition_POST(self, data: ModifyPartitionV2) \
-> StorageResponseV2: -> StorageResponseV2:
log.debug(data) log.debug(data)
self.locked_probe_data = True
disk = self.model._one(id=data.disk_id) disk = self.model._one(id=data.disk_id)
partition = self.get_partition(disk, data.partition.number) partition = self.get_partition(disk, data.partition.number)
self.delete_partition(partition) self.delete_partition(partition)
@ -777,6 +774,7 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
async def v2_edit_partition_POST(self, data: ModifyPartitionV2) \ async def v2_edit_partition_POST(self, data: ModifyPartitionV2) \
-> StorageResponseV2: -> StorageResponseV2:
log.debug(data) log.debug(data)
self.locked_probe_data = True
disk = self.model._one(id=data.disk_id) disk = self.model._one(id=data.disk_id)
partition = self.get_partition(disk, data.partition.number) partition = self.get_partition(disk, data.partition.number)
if data.partition.size not in (None, partition.size) \ if data.partition.size not in (None, partition.size) \
@ -798,10 +796,19 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
self.partition_disk_handler(disk, spec, partition=partition) self.partition_disk_handler(disk, spec, partition=partition)
return await self.v2_GET() return await self.v2_GET()
async def dry_run_wait_probe_POST(self) -> None:
if not self.app.opts.dry_run:
raise NotImplementedError
# This will start the probe task if not yet started.
self.ensure_probing()
await self._probe_task.task
@with_context(name='probe_once', description='restricted={restricted}') @with_context(name='probe_once', description='restricted={restricted}')
async def _probe_once(self, *, context, restricted): async def _probe_once(self, *, context, restricted):
if restricted: if restricted:
probe_types = {'blockdev'} probe_types = {'blockdev', 'filesystem'}
fname = 'probe-data-restricted.json' fname = 'probe-data-restricted.json'
key = "ProbeDataRestricted" key = "ProbeDataRestricted"
else: else:
@ -822,7 +829,11 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
with open(fpath, 'w') as fp: with open(fpath, 'w') as fp:
json.dump(storage, fp, indent=4) json.dump(storage, fp, indent=4)
self.app.note_file_for_apport(key, fpath) self.app.note_file_for_apport(key, fpath)
if not self.locked_probe_data:
self.queued_probe_data = None
self.model.load_probe_data(storage) self.model.load_probe_data(storage)
else:
self.queued_probe_data = storage
@with_context() @with_context()
async def _probe(self, *, context=None): async def _probe(self, *, context=None):
@ -917,8 +928,11 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
f'using {target}') f'using {target}')
use_lvm = name == 'lvm' use_lvm = name == 'lvm'
password = layout.get('password', None) password = layout.get('password', None)
self.guided(GuidedChoiceV2(target=target, use_lvm=use_lvm, sizing_policy = SizingPolicy.from_string(
password=password)) layout.get('sizing-policy', None))
self.guided(
GuidedChoiceV2(target=target, use_lvm=use_lvm,
password=password, sizing_policy=sizing_policy))
def validate_layout_mode(self, mode): def validate_layout_mode(self, mode):
if mode not in ('reformat_disk', 'use_gap'): if mode not in ('reformat_disk', 'use_gap'):
@ -965,6 +979,14 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
loop.remove_reader(self._monitor.fileno()) loop.remove_reader(self._monitor.fileno())
def ensure_probing(self):
try:
self._probe_task.start_sync()
except TaskAlreadyRunningError:
log.debug('Skipping run of Probert - probe run already active')
else:
log.debug('Triggered Probert run on udev event')
def _udev_event(self): def _udev_event(self):
cp = run_command(['udevadm', 'settle', '-t', '0']) cp = run_command(['udevadm', 'settle', '-t', '0'])
if cp.returncode != 0: if cp.returncode != 0:
@ -981,12 +1003,7 @@ class FilesystemController(SubiquityController, FilesystemManipulator):
while select.select([self._monitor.fileno()], [], [], 0)[0]: while select.select([self._monitor.fileno()], [], [], 0)[0]:
action, dev = self._monitor.receive_device() action, dev = self._monitor.receive_device()
log.debug("_udev_event %s %s", action, dev) log.debug("_udev_event %s %s", action, dev)
try: self.ensure_probing()
self._probe_task.start_sync()
except TaskAlreadyRunningError:
log.debug('Skipping run of Probert - probe run already active')
else:
log.debug('Triggered Probert run on udev event')
def make_autoinstall(self): def make_autoinstall(self):
rendered = self.model.render() rendered = self.model.render()

View File

@ -34,6 +34,7 @@ from subiquitycore.async_helpers import (
) )
from subiquitycore.context import with_context from subiquitycore.context import with_context
from subiquitycore.file_util import write_file, generate_config_yaml from subiquitycore.file_util import write_file, generate_config_yaml
from subiquitycore.utils import log_process_streams
from subiquity.common.errorreport import ErrorReportKind from subiquity.common.errorreport import ErrorReportKind
from subiquity.common.types import ( from subiquity.common.types import (
@ -480,7 +481,11 @@ class InstallController(SubiquityController):
self.app, context, "in-target", "-t", self.tpath(), self.app, context, "in-target", "-t", self.tpath(),
"--", "unattended-upgrades", "-v", "--", "unattended-upgrades", "-v",
private_mounts=True) private_mounts=True)
try:
await self.unattended_upgrades_cmd.wait() await self.unattended_upgrades_cmd.wait()
except subprocess.CalledProcessError as cpe:
log_process_streams(logging.ERROR, cpe, 'Unattended upgrades')
context.description = f"FAILED to apply {policy} updates"
self.unattended_upgrades_cmd = None self.unattended_upgrades_cmd = None
self.unattended_upgrades_ctx = None self.unattended_upgrades_ctx = None

View File

@ -14,7 +14,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging import logging
from typing import Dict, Optional, Sequence from typing import Dict, Optional, Sequence, Tuple
import os import os
import pwd import pwd
@ -48,7 +48,7 @@ standard_non_latin_layouts = set(
default_desktop_user = 'ubuntu' default_desktop_user = 'ubuntu'
def latinizable(layout_code, variant_code): def latinizable(layout_code, variant_code) -> Optional[Tuple[str, str]]:
""" """
If this setting does not allow the typing of latin characters, If this setting does not allow the typing of latin characters,
return a setting that can be switched to one that can. return a setting that can be switched to one that can.

View File

@ -67,5 +67,5 @@ class LocaleController(SubiquityController):
async def POST(self, data: str): async def POST(self, data: str):
log.debug(data) log.debug(data)
self.model.switch_language(data) self.model.switch_language(data)
async_helpers.run_bg_task(self.model.try_gen_localedef()) async_helpers.run_bg_task(self.model.try_localectl_set_locale())
await self.configured() await self.configured()

View File

@ -155,6 +155,7 @@ class MirrorController(SubiquityController):
self._promote_mirror) self._promote_mirror)
self.apt_configurer = None self.apt_configurer = None
self.mirror_check: Optional[MirrorCheck] = None self.mirror_check: Optional[MirrorCheck] = None
self.autoinstall_apply_started = False
def load_autoinstall_data(self, data): def load_autoinstall_data(self, data):
if data is None: if data is None:
@ -273,6 +274,7 @@ class MirrorController(SubiquityController):
@with_context() @with_context()
async def apply_autoinstall_config(self, context): async def apply_autoinstall_config(self, context):
self.autoinstall_apply_started = True
await self.run_mirror_selection_or_fallback(context) await self.run_mirror_selection_or_fallback(context)
def on_geoip(self): def on_geoip(self):
@ -281,6 +283,12 @@ class MirrorController(SubiquityController):
self.cc_event.set() self.cc_event.set()
def on_source(self): def on_source(self):
if self.autoinstall_apply_started:
# Alternatively, we should cancel and restart the
# apply_autoinstall_config but this is out of scope.
raise RuntimeError("source model has changed but autoinstall"
" configuration is already being applied")
# FIXME disabled until we can sort out umount # FIXME disabled until we can sort out umount
# if self.apt_configurer is not None: # if self.apt_configurer is not None:
# await self.apt_configurer.cleanup() # await self.apt_configurer.cleanup()
@ -323,8 +331,14 @@ class MirrorController(SubiquityController):
async def run_mirror_testing(self, output: io.StringIO) -> None: async def run_mirror_testing(self, output: io.StringIO) -> None:
await self.source_configured_event.wait() await self.source_configured_event.wait()
await self.apt_configurer.apply_apt_config(self.context, final=False) # If the source model changes at the wrong time, there is a chance that
await self.apt_configurer.run_apt_config_check(output) # self.apt_configurer will be replaced between the call to
# apply_apt_config and run_apt_config_check. Just make sure we still
# use the original one.
configurer = self.apt_configurer
await configurer.apply_apt_config(
self.context, final=False)
await configurer.run_apt_config_check(output)
async def wait_config(self): async def wait_config(self):
await self._apply_apt_config_task.wait() await self._apply_apt_config_task.wait()

View File

@ -239,13 +239,26 @@ class NetworkController(BaseNetworkController, SubiquityController):
@with_context() @with_context()
async def apply_autoinstall_config(self, context): async def apply_autoinstall_config(self, context):
want_apply_config = True
if self.ai_data is None: if self.ai_data is None:
if not await self.model.is_nm_enabled():
with context.child("wait_initial_config"): with context.child("wait_initial_config"):
await self.initial_config await self.initial_config
self.update_initial_configs() self.update_initial_configs()
self.apply_config(context) self.apply_config(context)
else:
log.debug("NetworkManager is enabled and no network"
" autoinstall section was found. Not applying"
" network settings.")
want_apply_config = False
if want_apply_config:
with context.child("wait_for_apply"): with context.child("wait_for_apply"):
await self.apply_config_task.wait() await self.apply_config_task.wait()
else:
# Make sure we have read at least once the routing table.
# Careful, the following is a blocking call. But running it in a
# separate thread without locking sounds unsafe too.
self.network_event_receiver.probe_default_routes()
self.model.has_network = self.network_event_receiver.has_default_route self.model.has_network = self.network_event_receiver.has_default_route
async def _apply_config(self, *, context=None, silent=False): async def _apply_config(self, *, context=None, silent=False):
@ -364,7 +377,7 @@ class NetworkController(BaseNetworkController, SubiquityController):
if state == WLANSupportInstallState.INSTALLING: if state == WLANSupportInstallState.INSTALLING:
self.pending_wlan_devices.add(dev) self.pending_wlan_devices.add(dev)
return return
elif state in [WLANSupportInstallState.FAILED. elif state in [WLANSupportInstallState.FAILED,
WLANSupportInstallState.NOT_AVAILABLE]: WLANSupportInstallState.NOT_AVAILABLE]:
return return
# WLANSupportInstallState.DONE falls through # WLANSupportInstallState.DONE falls through

View File

@ -100,6 +100,18 @@ class ShutdownController(SubiquityController):
if self.opts.dry_run: if self.opts.dry_run:
os.makedirs(target_logs, exist_ok=True) os.makedirs(target_logs, exist_ok=True)
else: else:
# Preserve ephemeral boot cloud-init logs if applicable
cloudinit_logs = [
cloudinit_log
for cloudinit_log in (
"/var/log/cloud-init.log",
"/var/log/cloud-init-output.log"
)
if os.path.exists(cloudinit_log)
]
if cloudinit_logs:
await arun_command(
['cp', '-a'] + cloudinit_logs + ['/var/log/installer'])
await arun_command( await arun_command(
['cp', '-aT', '/var/log/installer', target_logs]) ['cp', '-aT', '/var/log/installer', target_logs])
# Close the permissions from group writes on the target. # Close the permissions from group writes on the target.

View File

@ -21,17 +21,27 @@ from subiquitycore.tests.parameterized import parameterized
from subiquitycore.snapd import AsyncSnapd, get_fake_connection from subiquitycore.snapd import AsyncSnapd, get_fake_connection
from subiquitycore.tests.mocks import make_app from subiquitycore.tests.mocks import make_app
from subiquitycore.utils import matching_dicts
from subiquitycore.tests.util import random_string from subiquitycore.tests.util import random_string
from subiquity.common.filesystem import gaps from subiquity.common.filesystem import gaps
from subiquity.common.filesystem.actions import DeviceAction
from subiquity.common.types import ( from subiquity.common.types import (
AddPartitionV2,
Bootloader, Bootloader,
Gap,
GapUsable,
GuidedChoiceV2, GuidedChoiceV2,
GuidedStorageTargetReformat, GuidedStorageTargetReformat,
GuidedStorageTargetResize, GuidedStorageTargetResize,
GuidedStorageTargetUseGap, GuidedStorageTargetUseGap,
ModifyPartitionV2,
Partition,
ProbeStatus, ProbeStatus,
ReformatDisk,
SizingPolicy,
) )
from subiquity.models.filesystem import dehumanize_size
from subiquity.models.tests.test_filesystem import ( from subiquity.models.tests.test_filesystem import (
make_disk, make_disk,
make_model, make_model,
@ -48,18 +58,23 @@ bootloaders_and_ptables = [(bl, pt)
class TestSubiquityControllerFilesystem(IsolatedAsyncioTestCase): class TestSubiquityControllerFilesystem(IsolatedAsyncioTestCase):
MOCK_PREFIX = 'subiquity.server.controllers.filesystem.'
def setUp(self): def setUp(self):
self.app = make_app() self.app = make_app()
self.app.opts.bootloader = 'UEFI' self.app.opts.bootloader = 'UEFI'
self.app.report_start_event = mock.Mock() self.app.report_start_event = mock.Mock()
self.app.report_finish_event = mock.Mock() self.app.report_finish_event = mock.Mock()
self.app.prober = mock.Mock() self.app.prober = mock.Mock()
self.app.block_log_dir = '/inexistent'
self.app.note_file_for_apport = mock.Mock()
self.fsc = FilesystemController(app=self.app) self.fsc = FilesystemController(app=self.app)
self.fsc._configured = True self.fsc._configured = True
async def test_probe_restricted(self): async def test_probe_restricted(self):
await self.fsc._probe_once(context=None, restricted=True) await self.fsc._probe_once(context=None, restricted=True)
self.app.prober.get_storage.assert_called_with({'blockdev'}) expected = {'blockdev', 'filesystem'}
self.app.prober.get_storage.assert_called_with(expected)
async def test_probe_os_prober_false(self): async def test_probe_os_prober_false(self):
self.app.opts.use_os_prober = False self.app.opts.use_os_prober = False
@ -74,6 +89,197 @@ class TestSubiquityControllerFilesystem(IsolatedAsyncioTestCase):
actual = self.app.prober.get_storage.call_args.args[0] actual = self.app.prober.get_storage.call_args.args[0]
self.assertTrue({'defaults', 'os'} <= actual) self.assertTrue({'defaults', 'os'} <= actual)
async def test_probe_once_fs_configured(self):
self.fsc._configured = True
self.fsc.queued_probe_data = None
with mock.patch.object(self.fsc.model, 'load_probe_data') as load:
await self.fsc._probe_once(restricted=True)
self.assertIsNone(self.fsc.queued_probe_data)
load.assert_not_called()
@mock.patch('subiquity.server.controllers.filesystem.open',
mock.mock_open())
async def test_probe_once_locked_probe_data(self):
self.fsc._configured = False
self.fsc.locked_probe_data = True
self.fsc.queued_probe_data = None
self.app.prober.get_storage = mock.Mock(return_value={})
with mock.patch.object(self.fsc.model, 'load_probe_data') as load:
await self.fsc._probe_once(restricted=True)
self.assertEqual(self.fsc.queued_probe_data, {})
load.assert_not_called()
@mock.patch('subiquity.server.controllers.filesystem.open',
mock.mock_open())
async def test_probe_once_unlocked_probe_data(self):
self.fsc._configured = False
self.fsc.locked_probe_data = False
self.fsc.queued_probe_data = None
self.app.prober.get_storage = mock.Mock(return_value={})
with mock.patch.object(self.fsc.model, 'load_probe_data') as load:
await self.fsc._probe_once(restricted=True)
self.assertIsNone(self.fsc.queued_probe_data, {})
load.assert_called_once_with({})
async def test_v2_reset_POST_no_queued_data(self):
self.fsc.queued_probe_data = None
with mock.patch.object(self.fsc.model, 'load_probe_data') as load:
await self.fsc.v2_reset_POST()
load.assert_not_called()
async def test_v2_reset_POST_queued_data(self):
self.fsc.queued_probe_data = {}
with mock.patch.object(self.fsc.model, 'load_probe_data') as load:
await self.fsc.v2_reset_POST()
load.assert_called_once_with({})
async def test_v2_ensure_transaction_POST(self):
self.fsc.locked_probe_data = False
await self.fsc.v2_ensure_transaction_POST()
self.assertTrue(self.fsc.locked_probe_data)
async def test_v2_reformat_disk_POST(self):
self.fsc.locked_probe_data = False
with mock.patch.object(self.fsc, 'reformat') as reformat:
await self.fsc.v2_reformat_disk_POST(
ReformatDisk(disk_id='dev-sda'))
self.assertTrue(self.fsc.locked_probe_data)
reformat.assert_called_once()
@mock.patch(MOCK_PREFIX + 'boot.is_boot_device',
mock.Mock(return_value=True))
async def test_v2_add_boot_partition_POST_existing_bootloader(self):
self.fsc.locked_probe_data = False
with mock.patch.object(self.fsc, 'add_boot_disk') as add_boot_disk:
with self.assertRaisesRegex(ValueError,
r'already\ has\ bootloader'):
await self.fsc.v2_add_boot_partition_POST('dev-sda')
self.assertTrue(self.fsc.locked_probe_data)
add_boot_disk.assert_not_called()
@mock.patch(MOCK_PREFIX + 'boot.is_boot_device',
mock.Mock(return_value=False))
@mock.patch(MOCK_PREFIX + 'DeviceAction.supported',
mock.Mock(return_value=[]))
async def test_v2_add_boot_partition_POST_not_supported(self):
self.fsc.locked_probe_data = False
with mock.patch.object(self.fsc, 'add_boot_disk') as add_boot_disk:
with self.assertRaisesRegex(ValueError,
r'does\ not\ support\ boot'):
await self.fsc.v2_add_boot_partition_POST('dev-sda')
self.assertTrue(self.fsc.locked_probe_data)
add_boot_disk.assert_not_called()
@mock.patch(MOCK_PREFIX + 'boot.is_boot_device',
mock.Mock(return_value=False))
@mock.patch(MOCK_PREFIX + 'DeviceAction.supported',
mock.Mock(return_value=[DeviceAction.TOGGLE_BOOT]))
async def test_v2_add_boot_partition_POST(self):
self.fsc.locked_probe_data = False
with mock.patch.object(self.fsc, 'add_boot_disk') as add_boot_disk:
await self.fsc.v2_add_boot_partition_POST('dev-sda')
self.assertTrue(self.fsc.locked_probe_data)
add_boot_disk.assert_called_once()
async def test_v2_add_partition_POST_changing_boot(self):
self.fsc.locked_probe_data = False
data = AddPartitionV2(
disk_id='dev-sda',
partition=Partition(
format='ext4',
mount='/',
boot=True,
), gap=Gap(
offset=1 << 20,
size=1000 << 20,
usable=GapUsable.YES,
))
with mock.patch.object(self.fsc, 'create_partition') as create_part:
with self.assertRaisesRegex(ValueError,
r'does\ not\ support\ changing\ boot'):
await self.fsc.v2_add_partition_POST(data)
self.assertTrue(self.fsc.locked_probe_data)
create_part.assert_not_called()
async def test_v2_add_partition_POST_too_large(self):
self.fsc.locked_probe_data = False
data = AddPartitionV2(
disk_id='dev-sda',
partition=Partition(
format='ext4',
mount='/',
size=2000 << 20,
), gap=Gap(
offset=1 << 20,
size=1000 << 20,
usable=GapUsable.YES,
))
with mock.patch.object(self.fsc, 'create_partition') as create_part:
with self.assertRaisesRegex(ValueError, r'too\ large'):
await self.fsc.v2_add_partition_POST(data)
self.assertTrue(self.fsc.locked_probe_data)
create_part.assert_not_called()
@mock.patch(MOCK_PREFIX + 'gaps.at_offset')
async def test_v2_add_partition_POST(self, at_offset):
at_offset.split = mock.Mock(return_value=[mock.Mock()])
self.fsc.locked_probe_data = False
data = AddPartitionV2(
disk_id='dev-sda',
partition=Partition(
format='ext4',
mount='/',
), gap=Gap(
offset=1 << 20,
size=1000 << 20,
usable=GapUsable.YES,
))
with mock.patch.object(self.fsc, 'create_partition') as create_part:
await self.fsc.v2_add_partition_POST(data)
self.assertTrue(self.fsc.locked_probe_data)
create_part.assert_called_once()
async def test_v2_delete_partition_POST(self):
self.fsc.locked_probe_data = False
data = ModifyPartitionV2(
disk_id='dev-sda',
partition=Partition(number=1),
)
with mock.patch.object(self.fsc, 'delete_partition') as del_part:
with mock.patch.object(self.fsc, 'get_partition'):
await self.fsc.v2_delete_partition_POST(data)
self.assertTrue(self.fsc.locked_probe_data)
del_part.assert_called_once()
async def test_v2_edit_partition_POST_change_boot(self):
self.fsc.locked_probe_data = False
data = ModifyPartitionV2(
disk_id='dev-sda',
partition=Partition(number=1, boot=True),
)
existing = Partition(number=1, size=1000 << 20, boot=False)
with mock.patch.object(self.fsc, 'partition_disk_handler') as handler:
with mock.patch.object(self.fsc, 'get_partition',
return_value=existing):
with self.assertRaisesRegex(ValueError, r'changing\ boot'):
await self.fsc.v2_edit_partition_POST(data)
self.assertTrue(self.fsc.locked_probe_data)
handler.assert_not_called()
async def test_v2_edit_partition_POST(self):
self.fsc.locked_probe_data = False
data = ModifyPartitionV2(
disk_id='dev-sda',
partition=Partition(number=1),
)
existing = Partition(number=1, size=1000 << 20, boot=False)
with mock.patch.object(self.fsc, 'partition_disk_handler') as handler:
with mock.patch.object(self.fsc, 'get_partition',
return_value=existing):
await self.fsc.v2_edit_partition_POST(data)
self.assertTrue(self.fsc.locked_probe_data)
handler.assert_called_once()
class TestGuided(IsolatedAsyncioTestCase): class TestGuided(IsolatedAsyncioTestCase):
boot_expectations = [ boot_expectations = [
@ -462,33 +668,75 @@ class TestGuidedV2(IsolatedAsyncioTestCase):
disk_size - (1 << 20), parts[-1].offset + parts[-1].size, disk_size - (1 << 20), parts[-1].offset + parts[-1].size,
disk_size) disk_size)
async def _sizing_setup(self, bootloader, ptable, disk_size, policy):
self._setup(bootloader, ptable, size=disk_size)
resp = await self.fsc.v2_guided_GET()
reformat = [target for target in resp.possible
if isinstance(target, GuidedStorageTargetReformat)][0]
data = GuidedChoiceV2(target=reformat,
use_lvm=True,
sizing_policy=policy)
await self.fsc.v2_guided_POST(data=data)
resp = await self.fsc.GET()
[vg] = matching_dicts(resp.config, type='lvm_volgroup')
[part_id] = vg['devices']
[part] = matching_dicts(resp.config, id=part_id)
part_size = part['size'] # already an int
[lvm_partition] = matching_dicts(resp.config, type='lvm_partition')
size = dehumanize_size(lvm_partition['size'])
return size, part_size
@parameterized.expand(bootloaders_and_ptables)
async def test_scaled_disk(self, bootloader, ptable):
size, part_size = await self._sizing_setup(
bootloader, ptable, 50 << 30, SizingPolicy.SCALED)
# expected to be about half, differing by boot and ptable types
self.assertLess(20 << 30, size)
self.assertLess(size, 30 << 30)
@parameterized.expand(bootloaders_and_ptables)
async def test_unscaled_disk(self, bootloader, ptable):
size, part_size = await self._sizing_setup(
bootloader, ptable, 50 << 30, SizingPolicy.ALL)
# there is some subtle differences in sizing depending on
# ptable/bootloader and how the rounding goes
self.assertLess(part_size - (5 << 20), size)
self.assertLess(size, part_size)
# but we should using most of the disk, minus boot partition(s)
self.assertLess(45 << 30, size)
class TestManualBoot(IsolatedAsyncioTestCase): class TestManualBoot(IsolatedAsyncioTestCase):
def _setup(self, bootloader, ptable, **kw): def _setup(self, bootloader, ptable, **kw):
self.app = make_app() self.app = make_app()
self.app.opts.bootloader = bootloader.value self.app.opts.bootloader = bootloader.value
self.fsc = FilesystemController(app=self.app) self.fsc = FilesystemController(app=self.app)
self.fsc.calculate_suggested_install_min = mock.Mock()
self.fsc.calculate_suggested_install_min.return_value = 10 << 30
self.fsc.model = self.model = make_model(bootloader) self.fsc.model = self.model = make_model(bootloader)
self.model.storage_version = 2
self.fsc._probe_task.task = mock.Mock()
self.fsc._get_system_task.task = mock.Mock()
@parameterized.expand(bootloaders_and_ptables) @parameterized.expand(bootloaders_and_ptables)
async def test_get_boot_disks_only(self, bootloader, ptable): async def test_get_boot_disks_only(self, bootloader, ptable):
self._setup(bootloader, ptable) self._setup(bootloader, ptable)
disk = make_disk(self.model) make_disk(self.model)
self.assertEqual([disk.id], resp = await self.fsc.v2_GET()
await self.fsc.v2_potential_boot_disks_GET()) [d] = resp.disks
self.assertTrue(d.can_be_boot_device)
@parameterized.expand(bootloaders_and_ptables)
async def test_get_boot_disks_none(self, bootloader, ptable):
self._setup(bootloader, ptable)
self.assertEqual([], await self.fsc.v2_potential_boot_disks_GET())
@parameterized.expand(bootloaders_and_ptables) @parameterized.expand(bootloaders_and_ptables)
async def test_get_boot_disks_all(self, bootloader, ptable): async def test_get_boot_disks_all(self, bootloader, ptable):
self._setup(bootloader, ptable) self._setup(bootloader, ptable)
d1 = make_disk(self.model) make_disk(self.model)
d2 = make_disk(self.model) make_disk(self.model)
self.assertEqual(set([d1.id, d2.id]), resp = await self.fsc.v2_GET()
set(await self.fsc.v2_potential_boot_disks_GET())) [d1, d2] = resp.disks
self.assertTrue(d1.can_be_boot_device)
self.assertTrue(d2.can_be_boot_device)
@parameterized.expand(bootloaders_and_ptables) @parameterized.expand(bootloaders_and_ptables)
async def test_get_boot_disks_some(self, bootloader, ptable): async def test_get_boot_disks_some(self, bootloader, ptable):
@ -500,11 +748,12 @@ class TestManualBoot(IsolatedAsyncioTestCase):
preserve=True) preserve=True)
if bootloader == Bootloader.NONE: if bootloader == Bootloader.NONE:
# NONE will always pass the boot check, even on a full disk # NONE will always pass the boot check, even on a full disk
expected = set([d1.id, d2.id]) bootable = set([d1.id, d2.id])
else: else:
expected = set([d2.id]) bootable = set([d2.id])
self.assertEqual(expected, resp = await self.fsc.v2_GET()
set(await self.fsc.v2_potential_boot_disks_GET())) for d in resp.disks:
self.assertEqual(d.id in bootable, d.can_be_boot_device)
class TestCoreBootInstallMethods(IsolatedAsyncioTestCase): class TestCoreBootInstallMethods(IsolatedAsyncioTestCase):

View File

@ -79,9 +79,10 @@ class LoggedCommandRunner:
-> subprocess.CompletedProcess: -> subprocess.CompletedProcess:
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
# .communicate() forces returncode to be set to a value # .communicate() forces returncode to be set to a value
assert(proc.returncode is not None) assert proc.returncode is not None
if proc.returncode != 0: if proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, proc.args) raise subprocess.CalledProcessError(
proc.returncode, proc.args, output=stdout, stderr=stderr)
else: else:
return subprocess.CompletedProcess( return subprocess.CompletedProcess(
proc.args, proc.returncode, stdout=stdout, stderr=stderr) proc.args, proc.returncode, stdout=stdout, stderr=stderr)

View File

@ -28,7 +28,10 @@ from unittest.mock import patch
from urllib.parse import unquote from urllib.parse import unquote
from subiquitycore.tests import SubiTestCase from subiquitycore.tests import SubiTestCase
from subiquitycore.utils import astart_command from subiquitycore.utils import (
astart_command,
matching_dicts,
)
default_timeout = 10 default_timeout = 10
@ -37,8 +40,7 @@ def match(items, **kw):
typename = kw.pop('_type', None) typename = kw.pop('_type', None)
if typename is not None: if typename is not None:
kw['$type'] = typename kw['$type'] = typename
return [item for item in items return matching_dicts(items, **kw)
if all(item.get(k) == v for k, v in kw.items())]
def timeout(multiplier=1): def timeout(multiplier=1):
@ -263,8 +265,9 @@ async def start_server_factory(factory, *args, **kwargs):
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def start_server(*args, **kwargs): async def start_server(*args, set_first_source=True, **kwargs):
async with start_server_factory(Server, *args, **kwargs) as instance: async with start_server_factory(Server, *args, **kwargs) as instance:
if set_first_source:
sources = await instance.get('/source') sources = await instance.get('/source')
if sources is None: if sources is None:
raise Exception('unexpected /source response') raise Exception('unexpected /source response')
@ -1411,6 +1414,37 @@ class TestRegression(TestAPI):
[p] = resp['disks'][0]['partitions'] [p] = resp['disks'][0]['partitions']
self.assertEqual(orig_p, p) self.assertEqual(orig_p, p)
@timeout()
async def test_no_change_edit_swap(self):
'''LP: 2002413 - editing a swap partition would fail with
> Exception: Filesystem(fstype='swap', ...) is already mounted
Make sure editing the partition is ok now.
'''
cfg = 'examples/simple.json'
extra = ['--storage-version', '2']
async with start_server(cfg, extra_args=extra) as inst:
resp = await inst.get('/storage/v2')
[d] = resp['disks']
[g] = d['partitions']
data = {
"disk_id": 'disk-sda',
"gap": g,
"partition": {
"size": 8589934592, # 8 GiB
"format": "swap",
}
}
resp = await inst.post('/storage/v2/add_partition', data)
[p, gap] = resp['disks'][0]['partitions']
self.assertEqual('swap', p['format'])
orig_p = p.copy()
data = {"disk_id": 'disk-sda', "partition": p}
resp = await inst.post('/storage/v2/edit_partition', data)
[p, gap] = resp['disks'][0]['partitions']
self.assertEqual(orig_p, p)
@timeout() @timeout()
async def test_can_create_unformatted_partition(self): async def test_can_create_unformatted_partition(self):
'''We want to offer the same list of fstypes for Subiquity and U-D-I, '''We want to offer the same list of fstypes for Subiquity and U-D-I,
@ -1436,6 +1470,81 @@ class TestRegression(TestAPI):
v1resp = await inst.get('/storage') v1resp = await inst.get('/storage')
self.assertEqual([], match(v1resp['config'], type='format')) self.assertEqual([], match(v1resp['config'], type='format'))
@timeout()
async def test_guided_v2_resize_logical_middle_partition(self):
'''LP: #2015521 - a logical partition that wasn't the physically last
logical partition was resized to allow creation of more partitions, but
the 1MiB space was not left between the newly created partition and the
physically last partition.'''
cfg = 'examples/threebuntu-on-msdos.json'
extra = ['--storage-version', '2']
async with start_server(cfg, extra_args=extra) as inst:
resp = await inst.get('/storage/v2/guided')
[resize] = match(resp['possible'], partition_number=5,
_type='GuidedStorageTargetResize')
data = {
'target': resize,
'use_lvm': False,
}
resp = await inst.post('/storage/v2/guided', data)
self.assertEqual(resize, resp['configured']['target'])
resp = await inst.get('/storage')
parts = match(resp['config'], type='partition', flag='logical')
logicals = []
for part in parts:
part['end'] = part['offset'] + part['size']
logicals.append(part)
logicals.sort(key=lambda p: p['offset'])
for i in range(len(logicals) - 1):
cur, nxt = logicals[i:i+2]
self.assertLessEqual(cur['end'] + (1 << 20), nxt['offset'],
f'partition overlap {cur} {nxt}')
@timeout()
async def test_probert_result_during_partitioning(self):
'''If a probert run finished during manual partition, we used to
load the probing data, essentially discarding changes made by the user
so far. This test creates a new partition, simulates the end of a
probert run, and then tries to edit the previously created partition.
The edit operation would fail in earlier versions, because the new
partition would be discarded.
'''
cfg = 'examples/simple.json'
extra = ['--storage-version', '2']
async with start_server(cfg, extra_args=extra) as inst:
names = ['locale', 'keyboard', 'source', 'network', 'proxy',
'mirror']
await inst.post('/meta/mark_configured', endpoint_names=names)
resp = await inst.get('/storage/v2')
[d] = resp['disks']
[g] = d['partitions']
data = {
'disk_id': 'disk-sda',
'gap': g,
'partition': {
'size': -1,
'mount': '/',
'format': 'ext4',
}
}
add_resp = await inst.post('/storage/v2/add_partition', data)
[sda] = add_resp['disks']
[root] = match(sda['partitions'], mount='/')
# Now let's make sure we get the results from a probert run to kick
# in.
await inst.post('/storage/dry_run_wait_probe')
data = {
'disk_id': 'disk-sda',
'partition': {
'number': root['number'],
}
}
# We should be able to modify the created partition.
await inst.post('/storage/v2/edit_partition', data)
class TestCancel(TestAPI): class TestCancel(TestAPI):
@timeout() @timeout()
@ -1619,7 +1728,8 @@ class TestAutoinstallServer(TestAPI):
'--autoinstall', 'examples/autoinstall-short.yaml', '--autoinstall', 'examples/autoinstall-short.yaml',
'--source-catalog', 'examples/install-sources.yaml', '--source-catalog', 'examples/install-sources.yaml',
] ]
async with start_server(cfg, extra_args=extra) as inst: async with start_server(cfg, extra_args=extra,
set_first_source=False) as inst:
view_request_unspecified, resp = await inst.get( view_request_unspecified, resp = await inst.get(
'/locale', '/locale',
full_response=True) full_response=True)
@ -1764,7 +1874,8 @@ class TestActiveDirectory(TestAPI):
'--kernel-cmdline', 'autoinstall', '--kernel-cmdline', 'autoinstall',
] ]
try: try:
async with start_server(cfg, extra_args=extra) as inst: async with start_server(cfg, extra_args=extra,
set_first_source=False) as inst:
endpoint = '/active_directory' endpoint = '/active_directory'
logdir = inst.output_base() logdir = inst.output_base()
self.assertIsNotNone(logdir) self.assertIsNotNone(logdir)

View File

@ -495,10 +495,10 @@ class FilesystemView(BaseView):
return TablePile(rows) return TablePile(rows)
def _build_buttons(self): def _build_buttons(self):
self.done = Toggleable(done_btn(_("Done"), on_press=self.done)) self.done_btn = Toggleable(done_btn(_("Done"), on_press=self.done))
return [ return [
self.done, self.done_btn,
reset_btn(_("Reset"), on_press=self.reset), reset_btn(_("Reset"), on_press=self.reset),
back_btn(_("Back"), on_press=self.cancel), back_btn(_("Back"), on_press=self.cancel),
] ]
@ -524,7 +524,7 @@ class FilesystemView(BaseView):
# This is an awful hack, actual thinking required: # This is an awful hack, actual thinking required:
self.lb.base_widget._select_first_selectable() self.lb.base_widget._select_first_selectable()
can_install = self.model.can_install() can_install = self.model.can_install()
self.done.enabled = can_install self.done_btn.enabled = can_install
if self.showing_guidance: if self.showing_guidance:
del self.frame.contents[0] del self.frame.contents[0]
guidance = self._guidance() guidance = self._guidance()

View File

@ -157,15 +157,27 @@ class VolGroupStretchy(Stretchy):
label = _('Save') label = _('Save')
devices = {} devices = {}
key = "" key = ""
encrypt = False
for d in existing.devices: for d in existing.devices:
if d.type == "dm_crypt": if d.type == "dm_crypt":
encrypt = True
# If the DM_Crypt object was created using information
# sent by the server (this happens when the passphrase was
# provided in the Guided Storage screen), it will not
# contain a key but a path to a keyfile (d.keyfile). The
# client may not have permission to read the keyfile so it
# seems simpler to just present an empty passphrase field
# and ask the user to fill the passphrase again if they
# want to make adjustments to the VG.
# TODO make this more user friendly.
if d.key is not None:
key = d.key key = d.key
d = d.volume d = d.volume
devices[d] = 'active' devices[d] = 'active'
initial = { initial = {
'devices': devices, 'devices': devices,
'name': existing.name, 'name': existing.name,
'encrypt': bool(key), 'encrypt': encrypt,
'passphrase': key, 'passphrase': key,
'confirm_passphrase': key, 'confirm_passphrase': key,
} }

View File

@ -21,6 +21,7 @@ configuration.
""" """
import logging import logging
import re import re
from typing import Optional
from urwid import connect_signal, Text from urwid import connect_signal, Text
@ -91,6 +92,7 @@ class FSTypeField(FormField):
class SizeWidget(StringEditor): class SizeWidget(StringEditor):
def __init__(self, form): def __init__(self, form):
self.form = form self.form = form
self.accurate_value: Optional[int] = None
super().__init__() super().__init__()
def lost_focus(self): def lost_focus(self):
@ -112,6 +114,9 @@ class SizeWidget(StringEditor):
('info_minor', ('info_minor',
_("Capped partition size at {size}").format( _("Capped partition size at {size}").format(
size=self.form.size_str))) size=self.form.size_str)))
# This will invoke self.form.clean_size() and it is expected that
# size_str (and therefore self.value) are propertly aligned.
self.accurate_value = self.form.size
else: else:
aligned_sz = align_up(sz, self.form.alignment) aligned_sz = align_up(sz, self.form.alignment)
aligned_sz_str = humanize_size(aligned_sz) aligned_sz_str = humanize_size(aligned_sz)
@ -120,6 +125,7 @@ class SizeWidget(StringEditor):
self.form.size.show_extra( self.form.size.show_extra(
('info_minor', _("Rounded size up to {size}").format( ('info_minor', _("Rounded size up to {size}").format(
size=aligned_sz_str))) size=aligned_sz_str)))
self.accurate_value = aligned_sz
class SizeField(FormField): class SizeField(FormField):

View File

@ -9,6 +9,7 @@ from subiquitycore.view import BaseView
from subiquity.client.controllers.filesystem import FilesystemController from subiquity.client.controllers.filesystem import FilesystemController
from subiquity.common.filesystem import gaps from subiquity.common.filesystem import gaps
from subiquity.models.filesystem import ( from subiquity.models.filesystem import (
MiB,
dehumanize_size, dehumanize_size,
) )
from subiquity.models.tests.test_filesystem import ( from subiquity.models.tests.test_filesystem import (
@ -64,6 +65,7 @@ class PartitionViewTests(unittest.TestCase):
gap = gaps.Gap(device=disk, offset=1 << 20, size=99 << 30) gap = gaps.Gap(device=disk, offset=1 << 20, size=99 << 30)
view, stretchy = make_partition_view(model, disk, gap=gap) view, stretchy = make_partition_view(model, disk, gap=gap)
view_helpers.enter_data(stretchy.form, valid_data) view_helpers.enter_data(stretchy.form, valid_data)
stretchy.form.size.widget.lost_focus()
view_helpers.click(stretchy.form.done_btn.base_widget) view_helpers.click(stretchy.form.done_btn.base_widget)
valid_data['mount'] = '/' valid_data['mount'] = '/'
valid_data['size'] = dehumanize_size(valid_data['size']) valid_data['size'] = dehumanize_size(valid_data['size'])
@ -82,6 +84,7 @@ class PartitionViewTests(unittest.TestCase):
view, stretchy = make_partition_view(model, disk, partition=partition) view, stretchy = make_partition_view(model, disk, partition=partition)
self.assertTrue(stretchy.form.done_btn.enabled) self.assertTrue(stretchy.form.done_btn.enabled)
view_helpers.enter_data(stretchy.form, form_data) view_helpers.enter_data(stretchy.form, form_data)
stretchy.form.size.widget.lost_focus()
view_helpers.click(stretchy.form.done_btn.base_widget) view_helpers.click(stretchy.form.done_btn.base_widget)
expected_data = { expected_data = {
'size': dehumanize_size(form_data['size']), 'size': dehumanize_size(form_data['size']),
@ -117,6 +120,7 @@ class PartitionViewTests(unittest.TestCase):
self.assertFalse(stretchy.form.size.enabled) self.assertFalse(stretchy.form.size.enabled)
self.assertTrue(stretchy.form.done_btn.enabled) self.assertTrue(stretchy.form.done_btn.enabled)
view_helpers.enter_data(stretchy.form, form_data) view_helpers.enter_data(stretchy.form, form_data)
stretchy.form.size.widget.lost_focus()
view_helpers.click(stretchy.form.done_btn.base_widget) view_helpers.click(stretchy.form.done_btn.base_widget)
expected_data = { expected_data = {
'fstype': 'xfs', 'fstype': 'xfs',
@ -184,6 +188,7 @@ class PartitionViewTests(unittest.TestCase):
self.assertEqual(stretchy.form.mount.value, "/boot/efi") self.assertEqual(stretchy.form.mount.value, "/boot/efi")
view_helpers.enter_data(stretchy.form, form_data) view_helpers.enter_data(stretchy.form, form_data)
stretchy.form.size.widget.lost_focus()
view_helpers.click(stretchy.form.done_btn.base_widget) view_helpers.click(stretchy.form.done_btn.base_widget)
expected_data = { expected_data = {
'size': dehumanize_size(form_data['size']), 'size': dehumanize_size(form_data['size']),
@ -252,3 +257,26 @@ class PartitionViewTests(unittest.TestCase):
view, stretchy = make_format_entire_view(model, disk) view, stretchy = make_format_entire_view(model, disk)
self.assertEqual( self.assertEqual(
stretchy.form.fstype.value, None) stretchy.form.fstype.value, None)
def test_create_partition_unaligned_size(self):
# In LP: #2013201, the user would type in 1.1G and the partition
# created would not be aligned to a MiB boundary.
unaligned_data = {
'size': '1.1G', # Corresponds to 1181116006.4 bytes (not an int)
'fstype': 'ext4',
}
valid_data = {
'mount': '/',
'size': 1127 * MiB, # ~1.10058 GiB
'use_swap': False,
'fstype': 'ext4',
}
model, disk = make_model_and_disk()
gap = gaps.Gap(device=disk, offset=1 << 20, size=99 << 30)
view, stretchy = make_partition_view(model, disk, gap=gap)
view_helpers.enter_data(stretchy.form, unaligned_data)
stretchy.form.size.widget.lost_focus()
view_helpers.click(stretchy.form.done_btn.base_widget)
view.controller.partition_disk_handler.assert_called_once_with(
stretchy.disk, valid_data, partition=None, gap=gap
)

View File

@ -46,6 +46,7 @@ from subiquitycore.ui.views.network import (
) )
from subiquitycore.utils import ( from subiquitycore.utils import (
arun_command, arun_command,
orig_environ,
run_command, run_command,
) )
@ -358,9 +359,13 @@ class BaseNetworkController(BaseController):
'systemd-networkd.service', 'systemd-networkd.service',
'systemd-networkd.socket'], 'systemd-networkd.socket'],
check=True) check=True)
env = orig_environ(None)
try: try:
await arun_command(['netplan', 'apply'], check=True) await arun_command(['netplan', 'apply'],
except subprocess.CalledProcessError: env=env, check=True)
except subprocess.CalledProcessError as cpe:
log.debug('CalledProcessError: '
f'stdout[{cpe.stdout}] stderr[{cpe.stderr}]')
error("apply") error("apply")
raise raise
if devs_to_down or devs_to_delete: if devs_to_down or devs_to_delete:

View File

@ -40,10 +40,16 @@ class TestOrigEnviron(SubiTestCase):
expected = {} expected = {}
self.assertEqual(expected, orig_environ(env)) self.assertEqual(expected, orig_environ(env))
def test_no_ld_library_path(self):
env = {'LD_LIBRARY_PATH': 'a'}
expected = {}
self.assertEqual(expected, orig_environ(env))
def test_practical(self): def test_practical(self):
snap = '/snap/subiquity/1234' snap = '/snap/subiquity/1234'
env = { env = {
'TERM': 'linux', 'TERM': 'linux',
'LD_LIBRARY_PATH': '/var/lib/snapd/lib/gl',
'PYTHONIOENCODING_ORIG': '', 'PYTHONIOENCODING_ORIG': '',
'PYTHONIOENCODING': 'utf-8', 'PYTHONIOENCODING': 'utf-8',
'SUBIQUITY_ROOT_ORIG': '', 'SUBIQUITY_ROOT_ORIG': '',

View File

@ -554,6 +554,10 @@ class Form(object, metaclass=MetaForm):
data = {} data = {}
for field in self._fields: for field in self._fields:
if field.enabled: if field.enabled:
accurate_value = getattr(field.widget, "accurate_value", None)
if accurate_value is not None:
data[field.field.name] = accurate_value
else:
data[field.field.name] = field.value data[field.field.name] = field.value
return data return data

View File

@ -19,7 +19,7 @@ import logging
import os import os
import random import random
import subprocess import subprocess
from typing import List, Sequence from typing import Any, Dict, List, Sequence
log = logging.getLogger("subiquitycore.utils") log = logging.getLogger("subiquitycore.utils")
@ -36,6 +36,8 @@ def _clean_env(env, *, locale=True):
def orig_environ(env): def orig_environ(env):
"""Generate an environment dict that is suitable for use for running
programs that live outside the snap."""
if env is None: if env is None:
env = os.environ env = os.environ
ret = env.copy() ret = env.copy()
@ -47,6 +49,7 @@ def orig_environ(env):
else: else:
del ret[key_to_restore] del ret[key_to_restore]
del ret[key] del ret[key]
ret.pop('LD_LIBRARY_PATH', None)
return ret return ret
@ -102,7 +105,7 @@ async def arun_command(cmd: Sequence[str], *,
stderr = stderr.decode(encoding) stderr = stderr.decode(encoding)
log.debug("arun_command %s exited with code %s", cmd, proc.returncode) log.debug("arun_command %s exited with code %s", cmd, proc.returncode)
# .communicate() forces returncode to be set to a value # .communicate() forces returncode to be set to a value
assert(proc.returncode is not None) assert proc.returncode is not None
if check and proc.returncode != 0: if check and proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, cmd, raise subprocess.CalledProcessError(proc.returncode, cmd,
stdout, stderr) stdout, stderr)
@ -139,6 +142,26 @@ def start_command(cmd: Sequence[str], *,
env=_clean_env(env, locale=clean_locale), **kw) env=_clean_env(env, locale=clean_locale), **kw)
def _log_stream(level: int, stream, name: str):
if stream:
log.log(level, f'{name}: ------------------------------------------')
for line in stream.splitlines():
log.log(level, line)
elif stream is None:
log.log(level, f'<{name} is None>')
else:
log.log(level, f'<{name} is empty>')
def log_process_streams(level: int,
cpe: subprocess.CalledProcessError,
command_msg: str):
log.log(level, f'{command_msg} exited with result: {cpe.returncode}')
_log_stream(level, cpe.stdout, 'stdout')
_log_stream(level, cpe.stderr, 'stderr')
log.log(level, '--------------------------------------------------')
# FIXME: replace with passlib and update package deps # FIXME: replace with passlib and update package deps
def crypt_password(passwd, algo='SHA-512'): def crypt_password(passwd, algo='SHA-512'):
# encryption algo - id pairs for crypt() # encryption algo - id pairs for crypt()
@ -173,3 +196,10 @@ def disable_subiquity():
"snap.subiquity.subiquity-service.service", "snap.subiquity.subiquity-service.service",
"serial-subiquity@*.service"]) "serial-subiquity@*.service"])
return return
def matching_dicts(items: Sequence[Dict[Any, Any]], **criteria):
"""Given an input sequence of dictionaries, return a list of dicts where
the supplied keyword arguments all match those items."""
return [item for item in items
if all(k in item and item[k] == v for k, v in criteria.items())]

View File

@ -91,7 +91,9 @@ class ConfigureController(SubiquityController):
def __update_locale_cmd(self, lang) -> List[str]: def __update_locale_cmd(self, lang) -> List[str]:
""" Add mocking cli to update-locale if in dry-run.""" """ Add mocking cli to update-locale if in dry-run."""
updateLocCmd = ["update-locale", "LANG={}".format(lang), # A fixed path should be ok here since all releases (so far) ship
# the locales package.
updateLocCmd = ["/usr/sbin/update-locale", "LANG={}".format(lang),
"--no-checks"] "--no-checks"]
if not self.app.opts.dry_run: if not self.app.opts.dry_run:
return updateLocCmd return updateLocCmd
@ -123,13 +125,12 @@ class ConfigureController(SubiquityController):
return True return True
async def __recommended_language_packs(self, lang) \ async def __recommended_language_packs(self, lang, env) \
-> Optional[List[str]]: -> Optional[List[str]]:
""" Return a list of package names recommended by """ Return a list of package names recommended by
check-language-support (or a fake list if in dryrun). check-language-support (or a fake list if in dryrun).
List returned can be empty on success. None for failure. List returned can be empty on success. None for failure.
""" """
clsCommand = "check-language-support"
# lang code may be separated by @, dot or spaces. # lang code may be separated by @, dot or spaces.
# clsLang = lang.split('@')[0].split('.')[0].split(' ')[0] # clsLang = lang.split('@')[0].split('.')[0].split(' ')[0]
pattern = re.compile(r'([^.@\s]+)', re.IGNORECASE) pattern = re.compile(r'([^.@\s]+)', re.IGNORECASE)
@ -154,13 +155,27 @@ class ConfigureController(SubiquityController):
# ever by just '.'. On the other hand in dry-run we want it pointing to # ever by just '.'. On the other hand in dry-run we want it pointing to
# '/' if not properly set. # '/' if not properly set.
snap_dir = snap_dir if snap_dir != '.' else '/' snap_dir = snap_dir if snap_dir != '.' else '/'
data_dir = os.path.join(snap_dir, "usr/share/language-selector") data_dir_base = "usr/share/language-selector"
data_dir = os.path.join(snap_dir, data_dir_base)
# jammy does not (yet?) ship language-selector seeded.
# being defensive to prevent crashes.
clsCommand = "check-language-support"
envcp = None
if not os.path.exists(data_dir): if not os.path.exists(data_dir):
log.error("Misconfigured snap environment pointed L-S-C data dir" log.error("Language selector data dir %s seems not to be part"
" to %s", data_dir) " of the snap.", data_dir)
# Try seeded L-S-C
data_dir = os.path.join(self.model.root, data_dir_base)
if not os.path.exists(data_dir):
log.error("Cannot find language selector data directory.")
return None return None
cp = await arun_command([clsCommand, "-d", data_dir, "-l", clsLang]) # The env parameter is only needed if the package isn't in the snap
envcp = env
clsCommand = os.path.join("/usr/bin/", clsCommand)
cp = await arun_command([clsCommand, "-d", data_dir, "-l", clsLang],
env=envcp)
if cp.returncode != 0: if cp.returncode != 0:
log.error('Command "%s" failed with return code %d', log.error('Command "%s" failed with return code %d',
cp.args, cp.returncode) cp.args, cp.returncode)
@ -181,7 +196,7 @@ class ConfigureController(SubiquityController):
""" Install recommended packages. """ Install recommended packages.
lang is expected to be one single language/locale. lang is expected to be one single language/locale.
""" """
packages = await self.__recommended_language_packs(lang) packages = await self.__recommended_language_packs(lang, env)
# Hardcoded path is necessary to ensure escaping out of the snap env. # Hardcoded path is necessary to ensure escaping out of the snap env.
aptCommand = "/usr/bin/apt" aptCommand = "/usr/bin/apt"
if packages is None: if packages is None: