← Back to team overview

curtin-dev team mailing list archive

[Merge] ~ogayot/curtin:retry-download into curtin:master

 

Olivier Gayot has proposed merging ~ogayot/curtin:retry-download into curtin:master.

Commit message:
system-install: make it possible to download packages only and specify retries

Requested reviews:
  curtin developers (curtin-dev)

For more details, see:
https://code.launchpad.net/~ogayot/curtin/+git/curtin/+merge/437897
-- 
Your team curtin developers is requested to review the proposed merge of ~ogayot/curtin:retry-download into curtin:master.
diff --git a/curtin/commands/__init__.py b/curtin/commands/__init__.py
index 089a166..51b91c6 100644
--- a/curtin/commands/__init__.py
+++ b/curtin/commands/__init__.py
@@ -1,12 +1,27 @@
 # This file is part of curtin. See LICENSE file for copyright and license info.
 
+class MutuallyExclusiveGroup:
+    def __init__(self, entries) -> None:
+        self.entries = entries
+
 
 def populate_one_subcmd(parser, options_dict, handler):
-    for ent in options_dict:
-        args = ent[0]
-        if not isinstance(args, (list, tuple)):
-            args = (args,)
-        parser.add_argument(*args, **ent[1])
+    for entry in options_dict:
+        def add_entry_to_parser(parser, entry):
+            args = entry[0]
+            if not isinstance(args, (list, tuple)):
+                args = (args,)
+            parser.add_argument(*args, **entry[1])
+
+        if isinstance(entry, MutuallyExclusiveGroup):
+            group_parser = parser.add_mutually_exclusive_group()
+            subentries = entry.entries
+        else:
+            group_parser = parser
+            subentries = [entry]
+
+        for subentry in subentries:
+            add_entry_to_parser(group_parser, subentry)
     parser.set_defaults(func=handler)
 
 # vi: ts=4 expandtab syntax=python
diff --git a/curtin/commands/system_install.py b/curtin/commands/system_install.py
index 6d7b736..2279d57 100644
--- a/curtin/commands/system_install.py
+++ b/curtin/commands/system_install.py
@@ -5,7 +5,7 @@ import sys
 
 import curtin.util as util
 
-from . import populate_one_subcmd
+from . import populate_one_subcmd, MutuallyExclusiveGroup
 from curtin.log import LOG
 from curtin import distro
 
@@ -19,7 +19,10 @@ def system_install_pkgs_main(args):
     try:
         distro.install_packages(
             pkglist=args.packages, target=args.target,
-            allow_daemons=args.allow_daemons)
+            allow_daemons=args.allow_daemons,
+            download_retries=args.download_retry_after,
+            download_only=args.download_only,
+            assume_downloaded=args.assume_downloaded)
     except util.ProcessExecutionError as e:
         LOG.warn("system install failed for %s: %s" % (args.packages, e))
         exit_code = e.exit_code
@@ -27,6 +30,19 @@ def system_install_pkgs_main(args):
     sys.exit(exit_code)
 
 
+MUTUALLY_EXCLUSIVE_DOWNLOAD_OPTIONS = (
+    ((('--assume-downloaded',),
+      {'help': ('assume packages to install have already been downloaded.'
+                ' not supported on SUSE distro family.'),
+       'action': 'store_true'}),
+     (('--download-only',),
+      {'help': ('do not install/upgrade packages, only perform download.'
+                ' not supported on SUSE distro family.'),
+       'action': 'store_true'}),
+      )
+)
+
+
 CMD_ARGUMENTS = (
     ((('--allow-daemons',),
       {'help': ('do not disable running of daemons during upgrade.'),
@@ -36,6 +52,12 @@ CMD_ARGUMENTS = (
                 'default is env[TARGET_MOUNT_POINT]'),
        'action': 'store', 'metavar': 'TARGET',
        'default': os.environ.get('TARGET_MOUNT_POINT')}),
+     (('--download-retry-after',),
+      {'help': ('when a download fails, wait N seconds and try again.'
+                ' can be specified multiple times.'
+                ' not supported on SUSE distro family.'),
+       'action': 'append', 'nargs': '*'}),
+     MutuallyExclusiveGroup(MUTUALLY_EXCLUSIVE_DOWNLOAD_OPTIONS),
      ('packages',
       {'help': 'the list of packages to install',
        'metavar': 'PACKAGES', 'action': 'store', 'nargs': '+'}),
diff --git a/curtin/commands/system_upgrade.py b/curtin/commands/system_upgrade.py
index d4f6735..ac9aef6 100644
--- a/curtin/commands/system_upgrade.py
+++ b/curtin/commands/system_upgrade.py
@@ -18,7 +18,8 @@ def system_upgrade_main(args):
     exit_code = 0
     try:
         distro.system_upgrade(target=args.target,
-                              allow_daemons=args.allow_daemons)
+                              allow_daemons=args.allow_daemons,
+                              download_retries=args.download_retry_after)
     except util.ProcessExecutionError as e:
         LOG.warn("system upgrade failed: %s" % e)
         exit_code = e.exit_code
@@ -35,6 +36,11 @@ CMD_ARGUMENTS = (
                 'default is env[TARGET_MOUNT_POINT]'),
        'action': 'store', 'metavar': 'TARGET',
        'default': os.environ.get('TARGET_MOUNT_POINT')}),
+     (('--download-retry-after',),
+      {'help': ('when a download fails, wait N seconds and try again.'
+                ' can be specified multiple times.'
+                ' not supported on SUSE distro family.'),
+       'action': 'append', 'nargs': '*'}),
      )
 )
 
diff --git a/curtin/distro.py b/curtin/distro.py
index 4266278..0bad590 100644
--- a/curtin/distro.py
+++ b/curtin/distro.py
@@ -6,6 +6,7 @@ import re
 import shutil
 import tempfile
 import textwrap
+from typing import Optional, Sequence
 
 from .paths import target_path
 from .util import (
@@ -276,7 +277,9 @@ def apt_update(target=None, env=None, force=False, comment=None,
 
 
 def run_apt_command(mode, args=None, opts=None, env=None, target=None,
-                    execute=True, allow_daemons=False, clean=True):
+                    execute=True, allow_daemons=False, clean=True,
+                    download_retries: Optional[Sequence[int]]=None,
+                    download_only=False, assume_downloaded=False):
     defopts = ['--quiet', '--assume-yes',
                '--option=Dpkg::options::=--force-unsafe-io',
                '--option=Dpkg::Options::=--force-confold']
@@ -299,17 +302,69 @@ def run_apt_command(mode, args=None, opts=None, env=None, target=None,
     if not execute:
         return env, cmd
 
-    apt_update(target, env=env, comment=' '.join(cmd))
+    if not assume_downloaded:
+        apt_update(target, env=env, comment=' '.join(cmd))
+    if mode in ['dist-upgrade', 'install', 'upgrade']:
+        cmd_rv = apt_install(mode, args, opts=opts, env=env, target=target,
+                             allow_daemons=allow_daemons,
+                             download_retries=download_retries,
+                             download_only=download_only,
+                             assume_downloaded=assume_downloaded)
+        if clean and not download_only:
+            with ChrootableTarget(
+                    target, allow_daemons=allow_daemons) as inchroot:
+                inchroot.subp(['apt-get', 'clean'])
+        return cmd_rv
+
+    with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
+        return inchroot.subp(cmd, env=env)
+
+
+def apt_install(mode, packages=None, opts=None, env=None, target=None,
+                allow_daemons=False,
+                download_retries: Optional[Sequence[int]] = None,
+                download_only=False, assume_downloaded=False):
+    """ Install or upgrade a set or all the packages using apt-get. """
+    defopts = ['--quiet', '--assume-yes',
+               '--option=Dpkg::options::=--force-unsafe-io',
+               '--option=Dpkg::Options::=--force-confold']
+    if packages is None:
+        packages = []
+
+    if opts is None:
+        opts = []
+
+    if mode not in ['install', 'upgrade', 'dist-upgrade']:
+        raise ValueError(
+            'Unsupported mode "%s" for apt package install/upgrade' % mode)
+
+    if download_only and assume_downloaded:
+        raise ValueError(
+            'download-only and assume-downloaded options are incompatible')
+
+    # download first, then install/upgrade from cache
+    cmd = ['apt-get'] + defopts + opts + [mode]
+    dl_opts = ['--download-only']
+    # NOTE: it would feel natural to use --no-download here but sadly apt-get
+    # can fail with "Internal Error, Pathname to install is not absolute [...]"
+    # when packages were retrieved from the cdrom and not downloaded from the
+    # Internet.
+    inst_opts = []
+
     with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
-        cmd_rv = inchroot.subp(cmd, env=env)
-        if clean and mode in ['dist-upgrade', 'install', 'upgrade']:
-            inchroot.subp(['apt-get', 'clean'])
+        if not assume_downloaded:
+            cmd_rv = inchroot.subp(cmd + dl_opts + packages, env=env,
+                                   retries=download_retries)
+        if not download_only:
+            cmd_rv = inchroot.subp(cmd + inst_opts + packages, env=env)
 
     return cmd_rv
 
 
 def run_yum_command(mode, args=None, opts=None, env=None, target=None,
-                    execute=True, allow_daemons=False):
+                    execute=True, allow_daemons=False,
+                    download_retries: Optional[Sequence[int]] = None,
+                    download_only=False, assume_downloaded=False):
     defopts = ['--assumeyes', '--quiet']
 
     if args is None:
@@ -330,14 +385,19 @@ def run_yum_command(mode, args=None, opts=None, env=None, target=None,
 
     if mode in ["install", "update", "upgrade"]:
         return yum_install(mode, args, opts=opts, env=env, target=target,
-                           allow_daemons=allow_daemons)
+                           allow_daemons=allow_daemons,
+                           download_retries=download_retries,
+                           download_only=download_only,
+                           assume_downloaded=assume_downloaded)
 
     with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
         return inchroot.subp(cmd, env=env)
 
 
 def yum_install(mode, packages=None, opts=None, env=None, target=None,
-                allow_daemons=False):
+                allow_daemons=False,
+                download_retries: Optional[Sequence[int]] = None,
+                download_only=False, assume_downloaded=False):
 
     defopts = ['--assumeyes', '--quiet']
 
@@ -347,10 +407,17 @@ def yum_install(mode, packages=None, opts=None, env=None, target=None,
     if opts is None:
         opts = []
 
+    if download_retries is None:
+        download_retries = [1] * 10
+
     if mode not in ['install', 'update', 'upgrade']:
         raise ValueError(
             'Unsupported mode "%s" for yum package install/upgrade' % mode)
 
+    if download_only and assume_downloaded:
+        raise ValueError(
+            'download-only and assume-downloaded options are incompatible')
+
     # dnf is a drop in replacement for yum. On newer RH based systems yum
     # is just a sym link to dnf.
     if which('dnf', target=target):
@@ -364,9 +431,13 @@ def yum_install(mode, packages=None, opts=None, env=None, target=None,
 
     # rpm requires /dev /sys and /proc be mounted, use ChrootableTarget
     with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
-        inchroot.subp(cmd + dl_opts + packages,
-                      env=env, retries=[1] * 10)
-        return inchroot.subp(cmd + inst_opts + packages, env=env)
+        if not assume_downloaded:
+            cmd_rv = inchroot.subp(cmd + dl_opts + packages,
+                                   env=env, retries=download_retries)
+        if not download_only:
+            cmd_rv = inchroot.subp(cmd + inst_opts + packages, env=env)
+
+    return cmd_rv
 
 
 def rpm_get_dist_id(target=None):
@@ -380,7 +451,9 @@ def rpm_get_dist_id(target=None):
 
 
 def run_zypper_command(mode, args=None, opts=None, env=None, target=None,
-                       execute=True, allow_daemons=False):
+                       execute=True, allow_daemons=False,
+                       download_retries: Optional[Sequence[int]] = None,
+                       download_only=False, assume_downloaded=False):
     defopts = ['--non-interactive', '--non-interactive-include-reboot-patches',
                '--quiet']
 
@@ -395,12 +468,15 @@ def run_zypper_command(mode, args=None, opts=None, env=None, target=None,
     if not execute:
         return env, cmd
 
+    # TODO add support for retried downloads, download-only and no-download.
+
     with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
         return inchroot.subp(cmd, env=env)
 
 
 def system_upgrade(opts=None, target=None, env=None, allow_daemons=False,
-                   osfamily=None):
+                   osfamily=None,
+                   download_retries: Optional[Sequence[int]]=None):
     LOG.debug("Upgrading system in %s", target)
 
     if not osfamily:
@@ -421,12 +497,15 @@ def system_upgrade(opts=None, target=None, env=None, allow_daemons=False,
     for mode in distro_cfg[osfamily]['subcommands']:
         ret = distro_cfg[osfamily]['function'](
                 mode, opts=opts, target=target,
-                env=env, allow_daemons=allow_daemons)
+                env=env, allow_daemons=allow_daemons,
+                download_retries=download_retries)
     return ret
 
 
 def install_packages(pkglist, osfamily=None, opts=None, target=None, env=None,
-                     allow_daemons=False):
+                     allow_daemons=False,
+                     download_retries: Optional[Sequence[int]]=None,
+                     download_only=False, assume_downloaded=False):
     if isinstance(pkglist, str):
         pkglist = [pkglist]
 
@@ -445,7 +524,10 @@ def install_packages(pkglist, osfamily=None, opts=None, target=None, env=None,
                          osfamily)
 
     return install_cmd('install', args=pkglist, opts=opts, target=target,
-                       env=env, allow_daemons=allow_daemons)
+                       env=env, allow_daemons=allow_daemons,
+                       download_retries=download_retries,
+                       download_only=download_only,
+                       assume_downloaded=assume_downloaded)
 
 
 def has_pkg_available(pkg, target=None, osfamily=None):
diff --git a/tests/unittests/test_distro.py b/tests/unittests/test_distro.py
index 9851650..84c7275 100644
--- a/tests/unittests/test_distro.py
+++ b/tests/unittests/test_distro.py
@@ -2,6 +2,7 @@
 
 from unittest import skipIf
 import mock
+import os
 import sys
 
 from curtin import distro
@@ -293,6 +294,107 @@ class TestDistroIdentity(CiTestCase):
             self.mock_os_path.assert_called_with('/etc/redhat-release')
 
 
+class TestAptInstall(CiTestCase):
+    @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
+    @mock.patch.dict(os.environ, clear=True)
+    @mock.patch.object(distro, 'apt_install')
+    @mock.patch.object(distro, 'apt_update')
+    @mock.patch('curtin.util.subp')
+    def test_run_apt_command(self, m_subp, m_apt_update, m_apt_install):
+        # install with defaults
+        expected_env = {'DEBIAN_FRONTEND': 'noninteractive'}
+        expected_calls = [
+            mock.call('install', ['foobar', 'wark'],
+                      opts=[], env=expected_env, target=None,
+                      allow_daemons=False, download_retries=None,
+                      download_only=False, assume_downloaded=False)
+        ]
+
+        distro.run_apt_command('install', ['foobar', 'wark'])
+        m_apt_update.assert_called_once()
+        m_apt_install.assert_has_calls(expected_calls)
+        m_subp.assert_called_once_with(['apt-get', 'clean'], target='/')
+
+        m_subp.reset_mock()
+        m_apt_install.reset_mock()
+        m_apt_update.reset_mock()
+
+        # no clean option
+        distro.run_apt_command('install', ['foobar', 'wark'], clean=False)
+        m_apt_update.assert_called_once()
+        m_subp.assert_has_calls(expected_calls[:-1])
+
+    @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
+    @mock.patch('curtin.util.subp')
+    def test_apt_install(self, m_subp):
+        cmd_prefix = [
+            'apt-get', '--quiet', '--assume-yes',
+            '--option=Dpkg::options::=--force-unsafe-io',
+            '--option=Dpkg::Options::=--force-confold',
+        ]
+
+        expected_calls = [
+            mock.call(cmd_prefix + ['install', '--download-only']
+                                 + ['foobar', 'wark'],
+                      env=None, target='/', retries=None),
+            mock.call(cmd_prefix + ['install']
+                                 + ['foobar', 'wark'],
+                      env=None, target='/'),
+        ]
+
+        distro.apt_install('install', packages=['foobar', 'wark'])
+        m_subp.assert_has_calls(expected_calls)
+
+        expected_calls = [
+            mock.call(cmd_prefix + ['upgrade', '--download-only'],
+                      env=None, target='/', retries=None),
+            mock.call(cmd_prefix + ['upgrade'],
+                      env=None, target='/'),
+        ]
+
+        m_subp.reset_mock()
+        distro.apt_install('upgrade')
+        m_subp.assert_has_calls(expected_calls)
+
+        expected_calls = [
+            mock.call(cmd_prefix + ['dist-upgrade', '--download-only'],
+                      env=None, target='/', retries=None),
+            mock.call(cmd_prefix + ['dist-upgrade'],
+                      env=None, target='/'),
+        ]
+
+        m_subp.reset_mock()
+        distro.apt_install('dist-upgrade')
+        m_subp.assert_has_calls(expected_calls)
+
+        expected_dl_cmd = cmd_prefix + ['install', '--download-only', 'git']
+        expected_inst_cmd = cmd_prefix + ['install', 'git']
+
+        m_subp.reset_mock()
+        distro.apt_install('install', ['git'], download_only=True)
+        m_subp.assert_called_once_with(expected_dl_cmd, env=None, target='/',
+                                       retries=None)
+
+        m_subp.reset_mock()
+        distro.apt_install('install', ['git'], assume_downloaded=True)
+        m_subp.assert_called_once_with(expected_inst_cmd, env=None, target='/')
+
+
+    @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
+    @mock.patch('curtin.util.subp')
+    def test_apt_install_invalid_mode(self, m_subp):
+        with self.assertRaisesRegex(ValueError, 'Unsupported mode.*'):
+            distro.apt_install('update')
+        m_subp.assert_not_called()
+
+    @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
+    @mock.patch('curtin.util.subp')
+    def test_apt_install_conflict(self, m_subp):
+        with self.assertRaisesRegex(ValueError, '.*incompatible.*'):
+            distro.apt_install('install', ['git'],
+                               download_only=True, assume_downloaded=True)
+        m_subp.assert_not_called()
+
 class TestYumInstall(CiTestCase):
 
     @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
@@ -482,9 +584,13 @@ class TestSystemUpgrade(CiTestCase):
             '--option=Dpkg::options::=--force-unsafe-io',
             '--option=Dpkg::Options::=--force-confold']
         apt_cmd = apt_base + ['dist-upgrade'] + pkglist
+        dl_apt_cmd = apt_base + ['dist-upgrade', '--download-only'] + pkglist
+        inst_apt_cmd = apt_base + ['dist-upgrade'] + pkglist
         auto_remove = apt_base + ['autoremove']
         expected_calls = [
-            mock.call(apt_cmd, env=env, target=paths.target_path(target)),
+            mock.call(dl_apt_cmd, env=env, retries=None,
+                      target=paths.target_path(target)),
+            mock.call(inst_apt_cmd, env=env, target=paths.target_path(target)),
             mock.call(['apt-get', 'clean'], target=paths.target_path(target)),
             mock.call(auto_remove, env=env, target=paths.target_path(target)),
         ]

Follow ups