codecs: skip installation when running an offline install

ubuntu-restricted-addons is a multiverse package and is not included in
the pool. Therefore, trying to get it installed when offline leads to an
obvious error.

Instead of making the whole Ubuntu installation fail, we now warn and
skip installation of the package when performing an offline install.
In a perfect world, we should not have offered to install the package in
the first place, but in practice, we can run an offline installation as
the result of failed mirror testing (bad network for instance).

Signed-off-by: Olivier Gayot <olivier.gayot@canonical.com>
(cherry picked from commit 01ec1da86f)
This commit is contained in:
Olivier Gayot 2023-09-14 11:52:53 +02:00
parent 17b4753027
commit 5eba140cbb
12 changed files with 124 additions and 26 deletions

View File

@ -0,0 +1,21 @@
version: 1
locale: en_GB.UTF-8
network:
version: 2
ethernets:
all-eth:
match:
name: "en*"
dhcp6: yes
apt:
mirror-selection:
primary:
- uri: http://localhost/failed
fallback: offline-install
codecs:
install: true
identity:
realname: ''
username: ubuntu
password: '$6$wdAcoXrU039hKYPd$508Qvbe7ObUnxoj15DRCkzC3qO7edjH0VV7BPNRDYK4QR8ofJaEEF2heacn0QgD.f8pO8SNp83XNdWG6tocBM1'
hostname: ubuntu

View File

@ -68,6 +68,9 @@ validate () {
apt.security[1].uri='"http://ports.ubuntu.com/ubuntu-ports"' apt.security[1].uri='"http://ports.ubuntu.com/ubuntu-ports"'
;; ;;
esac esac
if [ "$testname" == autoinstall-fallback-offline ]; then
grep -F -- 'skipping installation of package ubuntu-restricted-addons' "$tmpdir"/subiquity-server-debug.log
fi
netplan generate --root $tmpdir netplan generate --root $tmpdir
elif [ "${mode}" = "system_setup" ]; then elif [ "${mode}" = "system_setup" ]; then
setup_mode="$2" setup_mode="$2"
@ -319,6 +322,18 @@ LANG=C.UTF-8 timeout --foreground 60 \
--source-catalog examples/sources/install.yaml --source-catalog examples/sources/install.yaml
validate install validate install
clean
testname=autoinstall-fallback-offline
LANG=C.UTF-8 timeout --foreground 60 \
python3 -m subiquity.cmd.tui \
--dry-run \
--output-base "$tmpdir" \
--machine-config examples/machines/simple.json \
--autoinstall examples/autoinstall/fallback-offline.yaml \
--kernel-cmdline autoinstall \
--source-catalog examples/sources/install.yaml
validate install
# The OOBE doesn't exist in WSL < 20.04 # The OOBE doesn't exist in WSL < 20.04
if [ "${RELEASE%.*}" -ge 20 ]; then if [ "${RELEASE%.*}" -ge 20 ]; then
# Test TCP connectivity (system_setup only) # Test TCP connectivity (system_setup only)

25
subiquity/common/pkg.py Normal file
View File

@ -0,0 +1,25 @@
# 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 attr
@attr.s(auto_attribs=True)
class TargetPkg:
name: str
# Some packages are not present in the pool and require a working network
# connection to be downloaded. By marking them with "skip_when_offline", we
# can skip them when running an offline install.
skip_when_offline: bool

View File

@ -14,8 +14,9 @@
# 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 Optional from typing import List, Optional
from subiquity.common.pkg import TargetPkg
from subiquity.common.types import AdConnectionInfo from subiquity.common.types import AdConnectionInfo
log = logging.getLogger("subiquity.models.ad") log = logging.getLogger("subiquity.models.ad")
@ -42,10 +43,14 @@ class AdModel:
else: else:
self.conn_info = AdConnectionInfo(domain_name=domain) self.conn_info = AdConnectionInfo(domain_name=domain)
async def target_packages(self): async def target_packages(self) -> List[TargetPkg]:
# NOTE Those packages must be present in the target system to allow # NOTE Those packages must be present in the target system to allow
# joining to a domain. # joining to a domain.
if self.do_join: if self.do_join:
return ["adcli", "realmd", "sssd"] return [
TargetPkg(name="adcli", skip_when_offline=False),
TargetPkg(name="realmd", skip_when_offline=False),
TargetPkg(name="sssd", skip_when_offline=False),
]
return [] return []

View File

@ -14,6 +14,9 @@
# 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 List
from subiquity.common.pkg import TargetPkg
log = logging.getLogger("subiquity.models.codecs") log = logging.getLogger("subiquity.models.codecs")
@ -21,9 +24,12 @@ log = logging.getLogger("subiquity.models.codecs")
class CodecsModel: class CodecsModel:
do_install = False do_install = False
async def target_packages(self): async def target_packages(self) -> List[TargetPkg]:
# NOTE currently, ubuntu-restricted-addons is an empty package that # NOTE currently, ubuntu-restricted-addons is an empty package that
# pulls relevant packages through Recommends: Ideally, we should make # pulls relevant packages through Recommends: Ideally, we should make
# sure to run the APT command for this package with the # sure to run the APT command for this package with the
# --install-recommends option. # --install-recommends option.
return ["ubuntu-restricted-addons"] if self.do_install else [] if not self.do_install:
return []
return [TargetPkg(name="ubuntu-restricted-addons", skip_when_offline=True)]

View File

@ -16,7 +16,9 @@
import locale import locale
import logging import logging
import subprocess import subprocess
from typing import List
from subiquity.common.pkg import TargetPkg
from subiquitycore.utils import arun_command, split_cmd_output from subiquitycore.utils import arun_command, split_cmd_output
log = logging.getLogger("subiquity.models.locale") log = logging.getLogger("subiquity.models.locale")
@ -60,7 +62,7 @@ class LocaleModel:
locale += ".UTF-8" locale += ".UTF-8"
return {"locale": locale} return {"locale": locale}
async def target_packages(self): async def target_packages(self) -> List[TargetPkg]:
if self.selected_language is None: if self.selected_language is None:
return [] return []
if self.locale_support != "langpack": if self.locale_support != "langpack":
@ -69,6 +71,7 @@ class LocaleModel:
if lang == "C": if lang == "C":
return [] return []
return await split_cmd_output( pkgs = await split_cmd_output(
self.chroot_prefix + ["check-language-support", "-l", lang], None self.chroot_prefix + ["check-language-support", "-l", lang], None
) )
return [TargetPkg(name=pkg, skip_when_offline=False) for pkg in pkgs]

View File

@ -15,8 +15,10 @@
import logging import logging
import subprocess import subprocess
from typing import List
from subiquity import cloudinit from subiquity import cloudinit
from subiquity.common.pkg import TargetPkg
from subiquitycore.models.network import NetworkModel as CoreNetworkModel from subiquitycore.models.network import NetworkModel as CoreNetworkModel
from subiquitycore.utils import arun_command from subiquitycore.utils import arun_command
@ -93,12 +95,12 @@ class NetworkModel(CoreNetworkModel):
} }
return r return r
async def target_packages(self): async def target_packages(self) -> List[TargetPkg]:
if self.needs_wpasupplicant: if not self.needs_wpasupplicant:
return ["wpasupplicant"]
else:
return [] return []
return [TargetPkg(name="wpasupplicant", skip_when_offline=False)]
async def is_nm_enabled(self): async def is_nm_enabled(self):
try: try:
cp = await arun_command(("nmcli", "networking"), check=True) cp = await arun_command(("nmcli", "networking"), check=True)

View File

@ -16,6 +16,8 @@
import logging import logging
from typing import List from typing import List
from subiquity.common.pkg import TargetPkg
log = logging.getLogger("subiquity.models.ssh") log = logging.getLogger("subiquity.models.ssh")
@ -29,8 +31,8 @@ class SSHModel:
# we go back to it. # we go back to it.
self.ssh_import_id = "" self.ssh_import_id = ""
async def target_packages(self): async def target_packages(self) -> List[TargetPkg]:
if self.install_server: if not self.install_server:
return ["openssh-server"]
else:
return [] return []
return [TargetPkg(name="openssh-server", skip_when_offline=False)]

View File

@ -20,7 +20,7 @@ import logging
import os import os
import uuid import uuid
from collections import OrderedDict from collections import OrderedDict
from typing import Any, Dict, Set, Tuple from typing import Any, Dict, List, Set, Tuple
import yaml import yaml
from cloudinit.config.schema import ( from cloudinit.config.schema import (
@ -39,6 +39,7 @@ except ImportError:
from curtin.config import merge_config from curtin.config import merge_config
from subiquity.common.pkg import TargetPkg
from subiquity.common.resources import get_users_and_groups from subiquity.common.resources import get_users_and_groups
from subiquity.server.types import InstallerChannels from subiquity.server.types import InstallerChannels
from subiquitycore.file_util import generate_timestamped_header, write_file from subiquitycore.file_util import generate_timestamped_header, write_file
@ -177,6 +178,9 @@ class SubiquityModel:
self.chroot_prefix = [] self.chroot_prefix = []
self.active_directory = AdModel() self.active_directory = AdModel()
# List of packages that will be installed using cloud-init on first
# boot.
self.cloud_init_packages: List[str] = []
self.codecs = CodecsModel() self.codecs = CodecsModel()
self.debconf_selections = DebconfSelectionsModel() self.debconf_selections = DebconfSelectionsModel()
self.drivers = DriversModel() self.drivers = DriversModel()
@ -189,7 +193,7 @@ class SubiquityModel:
self.mirror = MirrorModel() self.mirror = MirrorModel()
self.network = NetworkModel() self.network = NetworkModel()
self.oem = OEMModel() self.oem = OEMModel()
self.packages = [] self.packages: List[TargetPkg] = []
self.proxy = ProxyModel() self.proxy = ProxyModel()
self.snaplist = SnapListModel() self.snaplist = SnapListModel()
self.ssh = SSHModel() self.ssh = SSHModel()
@ -376,13 +380,15 @@ class SubiquityModel:
model = getattr(self, model_name) model = getattr(self, model_name)
if getattr(model, "make_cloudconfig", None): if getattr(model, "make_cloudconfig", None):
merge_config(config, model.make_cloudconfig()) merge_config(config, model.make_cloudconfig())
for package in self.cloud_init_packages:
merge_config(config, {"packages": list(self.cloud_init_packages)})
merge_cloud_init_config(config, self.userdata) merge_cloud_init_config(config, self.userdata)
if lsb_release()["release"] not in ("20.04", "22.04"): if lsb_release()["release"] not in ("20.04", "22.04"):
config.setdefault("write_files", []).append(CLOUDINIT_DISABLE_AFTER_INSTALL) config.setdefault("write_files", []).append(CLOUDINIT_DISABLE_AFTER_INSTALL)
self.validate_cloudconfig_schema(data=config, data_source="system install") self.validate_cloudconfig_schema(data=config, data_source="system install")
return config return config
async def target_packages(self): async def target_packages(self) -> List[TargetPkg]:
packages = list(self.packages) packages = list(self.packages)
for model_name in self._postinstall_model_names.all(): for model_name in self._postinstall_model_names.all():
meth = getattr(getattr(self, model_name), "target_packages", None) meth = getattr(getattr(self, model_name), "target_packages", None)

View File

@ -34,6 +34,7 @@ from curtin.config import merge_config
from curtin.util import get_efibootmgr, is_uefi_bootable from curtin.util import get_efibootmgr, is_uefi_bootable
from subiquity.common.errorreport import ErrorReportKind from subiquity.common.errorreport import ErrorReportKind
from subiquity.common.pkg import TargetPkg
from subiquity.common.types import ApplicationState, PackageInstallState from subiquity.common.types import ApplicationState, PackageInstallState
from subiquity.journald import journald_listen from subiquity.journald import journald_listen
from subiquity.models.filesystem import ActionRenderMode, Partition from subiquity.models.filesystem import ActionRenderMode, Partition
@ -689,11 +690,22 @@ class InstallController(SubiquityController):
{"autoinstall": self.app.make_autoinstall()} {"autoinstall": self.app.make_autoinstall()}
) )
write_file(autoinstall_path, autoinstall_config) write_file(autoinstall_path, autoinstall_config)
await self.configure_cloud_init(context=context) try:
if self.supports_apt(): if self.supports_apt():
packages = await self.get_target_packages(context=context) packages = await self.get_target_packages(context=context)
for package in packages: for package in packages:
await self.install_package(context=context, package=package) if package.skip_when_offline and not self.model.network.has_network:
log.warning(
"skipping installation of package %s when"
" performing an offline install.",
package.name,
)
continue
await self.install_package(context=context, package=package.name)
finally:
await self.configure_cloud_init(context=context)
if self.supports_apt():
if self.model.drivers.do_install: if self.model.drivers.do_install:
with context.child( with context.child(
"ubuntu-drivers-install", "installing third-party drivers" "ubuntu-drivers-install", "installing third-party drivers"
@ -720,7 +732,7 @@ class InstallController(SubiquityController):
await run_in_thread(self.model.configure_cloud_init) await run_in_thread(self.model.configure_cloud_init)
@with_context(description="calculating extra packages to install") @with_context(description="calculating extra packages to install")
async def get_target_packages(self, context): async def get_target_packages(self, context) -> List[TargetPkg]:
return await self.app.base_model.target_packages() return await self.app.base_model.target_packages()
@with_context(name="install_{package}", description="installing {package}") @with_context(name="install_{package}", description="installing {package}")

View File

@ -13,6 +13,7 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from subiquity.common.pkg import TargetPkg
from subiquity.server.controller import NonInteractiveController from subiquity.server.controller import NonInteractiveController
@ -25,7 +26,7 @@ class PackageController(NonInteractiveController):
} }
def load_autoinstall_data(self, data): def load_autoinstall_data(self, data):
self.model[:] = data self.model[:] = [TargetPkg(name=pkg, skip_when_offline=False) for pkg in data]
def make_autoinstall(self): def make_autoinstall(self):
return self.model return [pkg.name for pkg in self.model]

View File

@ -2133,7 +2133,7 @@ class TestActiveDirectory(TestAPI):
to be installed in the target system and whether they were to be installed in the target system and whether they were
referred to or not in the server log.""" referred to or not in the server log."""
expected_packages = await self.target_packages() expected_packages = await self.target_packages()
packages_lookup = {p: False for p in expected_packages} packages_lookup = {p.name: False for p in expected_packages}
log_path = os.path.join(log_dir, "subiquity-server-debug.log") log_path = os.path.join(log_dir, "subiquity-server-debug.log")
find_start = "finish: subiquity/Install/install/postinstall/install_{}:" find_start = "finish: subiquity/Install/install/postinstall/install_{}:"
log_status = " SUCCESS: installing {}" log_status = " SUCCESS: installing {}"