Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Package snap #4202

Merged
merged 19 commits into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cloudinit/config/cc_package_update_upgrade_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
- pwgen
- pastebinit
- [libpython3.8, 3.8.10-0ubuntu1~20.04.2]
- snap:
blackboxsw marked this conversation as resolved.
Show resolved Hide resolved
- certbot
- [juju, --edge]
- [lxd, --channel=5.15/stable]
- apt:
- mg
package_update: true
package_upgrade: true
package_reboot_if_required: true
Expand Down Expand Up @@ -96,7 +102,7 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None:
pkglist = util.get_cfg_option_list(cfg, "packages", [])

errors = []
if update or len(pkglist) or upgrade:
if update or upgrade:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this supplemental commit to avoid calling update_package_sources on all package managers. We don't need/want this in the event that not all package managers are represented in the user-data packages: list

try:
cloud.distro.update_package_sources()
except Exception as e:
Expand Down
39 changes: 32 additions & 7 deletions cloudinit/config/schemas/schema-cloud-config-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,21 @@
},
"additionalProperties": true
},
"package_item_definition": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
},
"minItems": 2,
"maxItems": 2
},
{
"type": "string"
}
]
},
"cc_ansible": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1924,19 +1939,29 @@
"properties": {
"packages": {
"type": "array",
"description": "A list of packages to install. Each entry in the list can be either a package name or a list with two entries, the first being the package name and the second being the specific package version to install.",
"description": "An array containing either a package specification, or an object consisting of a package manager key having a package specification value . A package specification can be either a package name or a list with two entries, the first being the package name and the second being the specific package version to install.",
"items": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
"type": "object",
"properties": {
"apt": {
"type": "array",
"items": {
"$ref": "#/$defs/package_item_definition"
}
},
"snap": {
"type": "array",
"items": {
"$ref": "#/$defs/package_item_definition"
}
}
blackboxsw marked this conversation as resolved.
Show resolved Hide resolved
},
"minItems": 2,
"maxItems": 2
"additionalProperties": false
},
{
"type": "string"
"$ref": "#/$defs/package_item_definition"
}
]
},
Expand Down
110 changes: 105 additions & 5 deletions cloudinit/distros/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,24 @@
import stat
import string
import urllib.parse
from collections import defaultdict
from io import StringIO
from typing import Any, Mapping, MutableMapping, Optional, Type
from typing import (
Any,
Dict,
Iterable,
List,
Mapping,
MutableMapping,
Optional,
Set,
Tuple,
Type,
)

import cloudinit.net.netops.iproute2 as iproute2
from cloudinit import (
helpers,
importer,
net,
persistence,
Expand All @@ -31,6 +44,8 @@
util,
)
from cloudinit.distros.networking import LinuxNetworking, Networking
from cloudinit.distros.package_management.package_manager import PackageManager
from cloudinit.distros.package_management.utils import known_package_managers
from cloudinit.distros.parsers import hosts
from cloudinit.features import ALLOW_EC2_MIRRORS_ON_NON_AWS_INSTANCE_TYPES
from cloudinit.net import activators, dhcp, eni, network_state, renderers
Expand Down Expand Up @@ -126,6 +141,8 @@ def __init__(self, name, cfg, paths):
dhcp.Udhcpc,
]
self.net_ops = iproute2.Iproute2
self._runner = helpers.Runners(paths)
self.package_managers: List[PackageManager] = []

def _unpickle(self, ci_pkl_version: int) -> None:
"""Perform deserialization fixes for Distro."""
Expand All @@ -139,9 +156,84 @@ def _unpickle(self, ci_pkl_version: int) -> None:
# missing expected instance state otherwise.
self.networking = self.networking_cls()

@abc.abstractmethod
def _extract_package_by_manager(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These next two methods still feel overly complicated to me. I'm open to any ideas.

self, pkglist: Iterable
) -> Tuple[Dict[Type[PackageManager], Set], Set]:
"""Transform the generic package list to package by package manager.

Additionally, include list of generic packages
"""
packages_by_manager = defaultdict(set)
generic_packages: Set = set()
for entry in pkglist:
if isinstance(entry, dict):
for package_manager, package_list in entry.items():
for definition in package_list:
if isinstance(definition, list):
definition = tuple(definition)
try:
packages_by_manager[
known_package_managers[package_manager]
].add(definition)
except KeyError:
LOG.error(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like we have unit test or integration test coverage of this case. Either would be fine.

"Cannot install packages under '%s' as it is "
"not a supported package manager!",
package_manager,
)
elif isinstance(entry, str):
generic_packages.add(entry)
else:
raise ValueError(
"Invalid 'packages' yaml specification. "
"Check schema definition."
)
return dict(packages_by_manager), generic_packages

def install_packages(self, pkglist):
raise NotImplementedError()
error_message = (
"Failed to install the following packages: %s. "
"See associated package manager logs for more details."
)
# If an entry hasn't been included with an explicit package name,
# add it to a 'generic' list of packages
(
packages_by_manager,
generic_packages,
) = self._extract_package_by_manager(pkglist)

# First install packages using package manager(s)
# supported by the distro
uninstalled = []
for manager in self.package_managers:
to_try = (
packages_by_manager.get(manager.__class__, set())
| generic_packages
)
if not to_try:
continue
uninstalled = manager.install_packages(to_try)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we only have generic_packages and len (distro.package_managers) == 1. We could provide a strict=True or force=True flag to install_packages to force using this package manager instead of try/fallback on unavailable. This could give us the ability to avoid checks on unavailable packages if we think that is something worth avoiding on debian or stable ubuntu.

failed = {
pkg for pkg in uninstalled if pkg not in generic_packages
}
if failed:
LOG.error(error_message, failed)
generic_packages = set(uninstalled)

# Now attempt any specified package managers not explicitly supported
# by distro
for manager, packages in packages_by_manager.items():
if manager.name in [p.name for p in self.package_managers]:
# We already installed/attempted these; don't try again
continue
uninstalled.extend(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Nothing is covering this in tests either. Maybe we want something like:
--- a/tests/unittests/config/test_cc_package_update_upgrade_install.py
+++ b/tests/unittests/config/test_cc_package_update_upgrade_install.py
@@ -75,6 +75,17 @@ class TestMultiplePackageManagers:
         assert mock.call(["snap", "install", "pkg1"]) in m_subp.call_args_list
         assert mock.call(["snap", "install", "pkg2"]) in m_subp.call_args_list
 
+    @mock.patch("cloudinit.subp.subp")
+    def test_explicit_snap_when_not_distro_default(self, m_subp, common_mocks):
+        import pdb; pdb.set_trace()
+        cloud = get_cloud("debian")
+        cfg = {"packages": [{"snap": ["pkg1", "pkg2"]}]}
+        handle("", cfg, cloud, [])
+
+        assert len(m_subp.call_args_list) == 2
+        assert mock.call(["snap", "install", "pkg1"]) in m_subp.call_args_list
+        assert mock.call(["snap", "install", "pkg2"]) in m_subp.call_args_list
+
     @mock.patch("cloudinit.subp.subp")
     def test_explicit_snap_version(self, m_subp, common_mocks):
         cloud = get_cloud("ubuntu")
  1. One thought this brings up is, if debian doesn't have snap command installed should the package_manager have a available/viable check to determine if it is even compatible? If not we will traceback with a called process error here on command not found. We can either add a PackageManager.is_applicable/available check or better handle a ProcessExecutionError when the PackageManager primary command is absent.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(original comment deleted).

This will be refactored in the next push

manager.from_config(self._runner, self._cfg).install_packages(
pkglist=packages
)
)

if uninstalled:
LOG.error(error_message, uninstalled)

def _write_network(self, settings):
"""Deprecated. Remove if/when arch and gentoo support renderers."""
Expand Down Expand Up @@ -202,11 +294,19 @@ def uses_systemd():

@abc.abstractmethod
def package_command(self, command, args=None, pkgs=None):
# Long-term, this method should be removed and callers refactored.
# Very few commands are going to be consistent across all package
# managers.
raise NotImplementedError()

@abc.abstractmethod
def update_package_sources(self):
raise NotImplementedError()
for manager in self.package_managers:
try:
manager.update_package_sources()
except Exception as e:
LOG.error(
"Failed to update package using %s: %s", manager.name, e
)

def get_primary_arch(self):
arch = os.uname()[4]
Expand Down
Loading