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

Proposed by Olivier Gayot
Status: Merged
Merged at revision: b1f4da3bec92356e8ef389c1c581cfdcd1b36c42
Proposed branch: ~ogayot/curtin:retry-download
Merge into: curtin:master
Diff against target: 469 lines (+256/-25)
5 files modified
curtin/commands/__init__.py (+20/-5)
curtin/commands/system_install.py (+24/-2)
curtin/commands/system_upgrade.py (+7/-1)
curtin/distro.py (+98/-16)
tests/unittests/test_distro.py (+107/-1)
Reviewer Review Type Date Requested Status
Michael Hudson-Doyle Approve
Server Team CI bot continuous-integration Approve
Review via email: mp+437897@code.launchpad.net

Commit message

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

Description of the change

Allow curtin system-install to specify the following options:

--download-only: the package(s) specified are downloaded but not installed/unpacked
--assume-downloaded: make curtin assume that the packages have already been downloaded. This is similar in essence to apt-get's --no-download option but only works on some of the package managers.

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

Seems broadly fine. Are there any docs for these commands to update?

Revision history for this message
Olivier Gayot (ogayot) wrote :

> Are there any docs for these commands to update?

I could not find any but I might have overlooked something.

Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

Oh well then :-)

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/curtin/commands/__init__.py b/curtin/commands/__init__.py
2index 089a166..51b91c6 100644
3--- a/curtin/commands/__init__.py
4+++ b/curtin/commands/__init__.py
5@@ -1,12 +1,27 @@
6 # This file is part of curtin. See LICENSE file for copyright and license info.
7
8+class MutuallyExclusiveGroup:
9+ def __init__(self, entries) -> None:
10+ self.entries = entries
11+
12
13 def populate_one_subcmd(parser, options_dict, handler):
14- for ent in options_dict:
15- args = ent[0]
16- if not isinstance(args, (list, tuple)):
17- args = (args,)
18- parser.add_argument(*args, **ent[1])
19+ for entry in options_dict:
20+ def add_entry_to_parser(parser, entry):
21+ args = entry[0]
22+ if not isinstance(args, (list, tuple)):
23+ args = (args,)
24+ parser.add_argument(*args, **entry[1])
25+
26+ if isinstance(entry, MutuallyExclusiveGroup):
27+ group_parser = parser.add_mutually_exclusive_group()
28+ subentries = entry.entries
29+ else:
30+ group_parser = parser
31+ subentries = [entry]
32+
33+ for subentry in subentries:
34+ add_entry_to_parser(group_parser, subentry)
35 parser.set_defaults(func=handler)
36
37 # vi: ts=4 expandtab syntax=python
38diff --git a/curtin/commands/system_install.py b/curtin/commands/system_install.py
39index 6d7b736..55bae07 100644
40--- a/curtin/commands/system_install.py
41+++ b/curtin/commands/system_install.py
42@@ -5,7 +5,7 @@ import sys
43
44 import curtin.util as util
45
46-from . import populate_one_subcmd
47+from . import populate_one_subcmd, MutuallyExclusiveGroup
48 from curtin.log import LOG
49 from curtin import distro
50
51@@ -19,7 +19,10 @@ def system_install_pkgs_main(args):
52 try:
53 distro.install_packages(
54 pkglist=args.packages, target=args.target,
55- allow_daemons=args.allow_daemons)
56+ allow_daemons=args.allow_daemons,
57+ download_retries=args.download_retry_after,
58+ download_only=args.download_only,
59+ assume_downloaded=args.assume_downloaded)
60 except util.ProcessExecutionError as e:
61 LOG.warn("system install failed for %s: %s" % (args.packages, e))
62 exit_code = e.exit_code
63@@ -27,6 +30,19 @@ def system_install_pkgs_main(args):
64 sys.exit(exit_code)
65
66
67+MUTUALLY_EXCLUSIVE_DOWNLOAD_OPTIONS = (
68+ ((('--assume-downloaded',),
69+ {'help': ('assume packages to install have already been downloaded.'
70+ ' not supported on SUSE distro family.'),
71+ 'action': 'store_true'}),
72+ (('--download-only',),
73+ {'help': ('do not install/upgrade packages, only perform download.'
74+ ' not supported on SUSE distro family.'),
75+ 'action': 'store_true'}),
76+ )
77+)
78+
79+
80 CMD_ARGUMENTS = (
81 ((('--allow-daemons',),
82 {'help': ('do not disable running of daemons during upgrade.'),
83@@ -36,6 +52,12 @@ CMD_ARGUMENTS = (
84 'default is env[TARGET_MOUNT_POINT]'),
85 'action': 'store', 'metavar': 'TARGET',
86 'default': os.environ.get('TARGET_MOUNT_POINT')}),
87+ (('--download-retry-after',),
88+ {'help': ('when a download fails, wait N seconds and try again.'
89+ ' can be specified multiple times.'
90+ ' not supported on SUSE distro family.'),
91+ 'action': 'append', 'nargs': '*'}),
92+ MutuallyExclusiveGroup(MUTUALLY_EXCLUSIVE_DOWNLOAD_OPTIONS),
93 ('packages',
94 {'help': 'the list of packages to install',
95 'metavar': 'PACKAGES', 'action': 'store', 'nargs': '+'}),
96diff --git a/curtin/commands/system_upgrade.py b/curtin/commands/system_upgrade.py
97index d4f6735..ac9aef6 100644
98--- a/curtin/commands/system_upgrade.py
99+++ b/curtin/commands/system_upgrade.py
100@@ -18,7 +18,8 @@ def system_upgrade_main(args):
101 exit_code = 0
102 try:
103 distro.system_upgrade(target=args.target,
104- allow_daemons=args.allow_daemons)
105+ allow_daemons=args.allow_daemons,
106+ download_retries=args.download_retry_after)
107 except util.ProcessExecutionError as e:
108 LOG.warn("system upgrade failed: %s" % e)
109 exit_code = e.exit_code
110@@ -35,6 +36,11 @@ CMD_ARGUMENTS = (
111 'default is env[TARGET_MOUNT_POINT]'),
112 'action': 'store', 'metavar': 'TARGET',
113 'default': os.environ.get('TARGET_MOUNT_POINT')}),
114+ (('--download-retry-after',),
115+ {'help': ('when a download fails, wait N seconds and try again.'
116+ ' can be specified multiple times.'
117+ ' not supported on SUSE distro family.'),
118+ 'action': 'append', 'nargs': '*'}),
119 )
120 )
121
122diff --git a/curtin/distro.py b/curtin/distro.py
123index 4266278..433004c 100644
124--- a/curtin/distro.py
125+++ b/curtin/distro.py
126@@ -6,6 +6,7 @@ import re
127 import shutil
128 import tempfile
129 import textwrap
130+from typing import Optional, Sequence
131
132 from .paths import target_path
133 from .util import (
134@@ -276,7 +277,9 @@ def apt_update(target=None, env=None, force=False, comment=None,
135
136
137 def run_apt_command(mode, args=None, opts=None, env=None, target=None,
138- execute=True, allow_daemons=False, clean=True):
139+ execute=True, allow_daemons=False, clean=True,
140+ download_retries: Optional[Sequence[int]] = None,
141+ download_only=False, assume_downloaded=False):
142 defopts = ['--quiet', '--assume-yes',
143 '--option=Dpkg::options::=--force-unsafe-io',
144 '--option=Dpkg::Options::=--force-confold']
145@@ -299,17 +302,69 @@ def run_apt_command(mode, args=None, opts=None, env=None, target=None,
146 if not execute:
147 return env, cmd
148
149- apt_update(target, env=env, comment=' '.join(cmd))
150+ if not assume_downloaded:
151+ apt_update(target, env=env, comment=' '.join(cmd))
152+ if mode in ['dist-upgrade', 'install', 'upgrade']:
153+ cmd_rv = apt_install(mode, args, opts=opts, env=env, target=target,
154+ allow_daemons=allow_daemons,
155+ download_retries=download_retries,
156+ download_only=download_only,
157+ assume_downloaded=assume_downloaded)
158+ if clean and not download_only:
159+ with ChrootableTarget(
160+ target, allow_daemons=allow_daemons) as inchroot:
161+ inchroot.subp(['apt-get', 'clean'])
162+ return cmd_rv
163+
164+ with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
165+ return inchroot.subp(cmd, env=env)
166+
167+
168+def apt_install(mode, packages=None, opts=None, env=None, target=None,
169+ allow_daemons=False,
170+ download_retries: Optional[Sequence[int]] = None,
171+ download_only=False, assume_downloaded=False):
172+ """ Install or upgrade a set or all the packages using apt-get. """
173+ defopts = ['--quiet', '--assume-yes',
174+ '--option=Dpkg::options::=--force-unsafe-io',
175+ '--option=Dpkg::Options::=--force-confold']
176+ if packages is None:
177+ packages = []
178+
179+ if opts is None:
180+ opts = []
181+
182+ if mode not in ['install', 'upgrade', 'dist-upgrade']:
183+ raise ValueError(
184+ 'Unsupported mode "%s" for apt package install/upgrade' % mode)
185+
186+ if download_only and assume_downloaded:
187+ raise ValueError(
188+ 'download-only and assume-downloaded options are incompatible')
189+
190+ # download first, then install/upgrade from cache
191+ cmd = ['apt-get'] + defopts + opts + [mode]
192+ dl_opts = ['--download-only']
193+ # NOTE: it would feel natural to use --no-download here but sadly apt-get
194+ # can fail with "Internal Error, Pathname to install is not absolute [...]"
195+ # when packages were retrieved from the cdrom and not downloaded from the
196+ # Internet.
197+ inst_opts = []
198+
199 with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
200- cmd_rv = inchroot.subp(cmd, env=env)
201- if clean and mode in ['dist-upgrade', 'install', 'upgrade']:
202- inchroot.subp(['apt-get', 'clean'])
203+ if not assume_downloaded:
204+ cmd_rv = inchroot.subp(cmd + dl_opts + packages, env=env,
205+ retries=download_retries)
206+ if not download_only:
207+ cmd_rv = inchroot.subp(cmd + inst_opts + packages, env=env)
208
209 return cmd_rv
210
211
212 def run_yum_command(mode, args=None, opts=None, env=None, target=None,
213- execute=True, allow_daemons=False):
214+ execute=True, allow_daemons=False,
215+ download_retries: Optional[Sequence[int]] = None,
216+ download_only=False, assume_downloaded=False):
217 defopts = ['--assumeyes', '--quiet']
218
219 if args is None:
220@@ -330,14 +385,19 @@ def run_yum_command(mode, args=None, opts=None, env=None, target=None,
221
222 if mode in ["install", "update", "upgrade"]:
223 return yum_install(mode, args, opts=opts, env=env, target=target,
224- allow_daemons=allow_daemons)
225+ allow_daemons=allow_daemons,
226+ download_retries=download_retries,
227+ download_only=download_only,
228+ assume_downloaded=assume_downloaded)
229
230 with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
231 return inchroot.subp(cmd, env=env)
232
233
234 def yum_install(mode, packages=None, opts=None, env=None, target=None,
235- allow_daemons=False):
236+ allow_daemons=False,
237+ download_retries: Optional[Sequence[int]] = None,
238+ download_only=False, assume_downloaded=False):
239
240 defopts = ['--assumeyes', '--quiet']
241
242@@ -347,10 +407,17 @@ def yum_install(mode, packages=None, opts=None, env=None, target=None,
243 if opts is None:
244 opts = []
245
246+ if download_retries is None:
247+ download_retries = [1] * 10
248+
249 if mode not in ['install', 'update', 'upgrade']:
250 raise ValueError(
251 'Unsupported mode "%s" for yum package install/upgrade' % mode)
252
253+ if download_only and assume_downloaded:
254+ raise ValueError(
255+ 'download-only and assume-downloaded options are incompatible')
256+
257 # dnf is a drop in replacement for yum. On newer RH based systems yum
258 # is just a sym link to dnf.
259 if which('dnf', target=target):
260@@ -364,9 +431,13 @@ def yum_install(mode, packages=None, opts=None, env=None, target=None,
261
262 # rpm requires /dev /sys and /proc be mounted, use ChrootableTarget
263 with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
264- inchroot.subp(cmd + dl_opts + packages,
265- env=env, retries=[1] * 10)
266- return inchroot.subp(cmd + inst_opts + packages, env=env)
267+ if not assume_downloaded:
268+ cmd_rv = inchroot.subp(cmd + dl_opts + packages,
269+ env=env, retries=download_retries)
270+ if not download_only:
271+ cmd_rv = inchroot.subp(cmd + inst_opts + packages, env=env)
272+
273+ return cmd_rv
274
275
276 def rpm_get_dist_id(target=None):
277@@ -380,7 +451,9 @@ def rpm_get_dist_id(target=None):
278
279
280 def run_zypper_command(mode, args=None, opts=None, env=None, target=None,
281- execute=True, allow_daemons=False):
282+ execute=True, allow_daemons=False,
283+ download_retries: Optional[Sequence[int]] = None,
284+ download_only=False, assume_downloaded=False):
285 defopts = ['--non-interactive', '--non-interactive-include-reboot-patches',
286 '--quiet']
287
288@@ -395,12 +468,15 @@ def run_zypper_command(mode, args=None, opts=None, env=None, target=None,
289 if not execute:
290 return env, cmd
291
292+ # TODO add support for retried downloads, download-only and no-download.
293+
294 with ChrootableTarget(target, allow_daemons=allow_daemons) as inchroot:
295 return inchroot.subp(cmd, env=env)
296
297
298 def system_upgrade(opts=None, target=None, env=None, allow_daemons=False,
299- osfamily=None):
300+ osfamily=None,
301+ download_retries: Optional[Sequence[int]] = None):
302 LOG.debug("Upgrading system in %s", target)
303
304 if not osfamily:
305@@ -421,12 +497,15 @@ def system_upgrade(opts=None, target=None, env=None, allow_daemons=False,
306 for mode in distro_cfg[osfamily]['subcommands']:
307 ret = distro_cfg[osfamily]['function'](
308 mode, opts=opts, target=target,
309- env=env, allow_daemons=allow_daemons)
310+ env=env, allow_daemons=allow_daemons,
311+ download_retries=download_retries)
312 return ret
313
314
315 def install_packages(pkglist, osfamily=None, opts=None, target=None, env=None,
316- allow_daemons=False):
317+ allow_daemons=False,
318+ download_retries: Optional[Sequence[int]] = None,
319+ download_only=False, assume_downloaded=False):
320 if isinstance(pkglist, str):
321 pkglist = [pkglist]
322
323@@ -445,7 +524,10 @@ def install_packages(pkglist, osfamily=None, opts=None, target=None, env=None,
324 osfamily)
325
326 return install_cmd('install', args=pkglist, opts=opts, target=target,
327- env=env, allow_daemons=allow_daemons)
328+ env=env, allow_daemons=allow_daemons,
329+ download_retries=download_retries,
330+ download_only=download_only,
331+ assume_downloaded=assume_downloaded)
332
333
334 def has_pkg_available(pkg, target=None, osfamily=None):
335diff --git a/tests/unittests/test_distro.py b/tests/unittests/test_distro.py
336index 9851650..5743475 100644
337--- a/tests/unittests/test_distro.py
338+++ b/tests/unittests/test_distro.py
339@@ -2,6 +2,7 @@
340
341 from unittest import skipIf
342 import mock
343+import os
344 import sys
345
346 from curtin import distro
347@@ -293,6 +294,107 @@ class TestDistroIdentity(CiTestCase):
348 self.mock_os_path.assert_called_with('/etc/redhat-release')
349
350
351+class TestAptInstall(CiTestCase):
352+ @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
353+ @mock.patch.dict(os.environ, clear=True)
354+ @mock.patch.object(distro, 'apt_install')
355+ @mock.patch.object(distro, 'apt_update')
356+ @mock.patch('curtin.util.subp')
357+ def test_run_apt_command(self, m_subp, m_apt_update, m_apt_install):
358+ # install with defaults
359+ expected_env = {'DEBIAN_FRONTEND': 'noninteractive'}
360+ expected_calls = [
361+ mock.call('install', ['foobar', 'wark'],
362+ opts=[], env=expected_env, target=None,
363+ allow_daemons=False, download_retries=None,
364+ download_only=False, assume_downloaded=False)
365+ ]
366+
367+ distro.run_apt_command('install', ['foobar', 'wark'])
368+ m_apt_update.assert_called_once()
369+ m_apt_install.assert_has_calls(expected_calls)
370+ m_subp.assert_called_once_with(['apt-get', 'clean'], target='/')
371+
372+ m_subp.reset_mock()
373+ m_apt_install.reset_mock()
374+ m_apt_update.reset_mock()
375+
376+ # no clean option
377+ distro.run_apt_command('install', ['foobar', 'wark'], clean=False)
378+ m_apt_update.assert_called_once()
379+ m_subp.assert_has_calls(expected_calls[:-1])
380+
381+ @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
382+ @mock.patch('curtin.util.subp')
383+ def test_apt_install(self, m_subp):
384+ cmd_prefix = [
385+ 'apt-get', '--quiet', '--assume-yes',
386+ '--option=Dpkg::options::=--force-unsafe-io',
387+ '--option=Dpkg::Options::=--force-confold',
388+ ]
389+
390+ expected_calls = [
391+ mock.call(cmd_prefix + ['install', '--download-only']
392+ + ['foobar', 'wark'],
393+ env=None, target='/', retries=None),
394+ mock.call(cmd_prefix + ['install']
395+ + ['foobar', 'wark'],
396+ env=None, target='/'),
397+ ]
398+
399+ distro.apt_install('install', packages=['foobar', 'wark'])
400+ m_subp.assert_has_calls(expected_calls)
401+
402+ expected_calls = [
403+ mock.call(cmd_prefix + ['upgrade', '--download-only'],
404+ env=None, target='/', retries=None),
405+ mock.call(cmd_prefix + ['upgrade'],
406+ env=None, target='/'),
407+ ]
408+
409+ m_subp.reset_mock()
410+ distro.apt_install('upgrade')
411+ m_subp.assert_has_calls(expected_calls)
412+
413+ expected_calls = [
414+ mock.call(cmd_prefix + ['dist-upgrade', '--download-only'],
415+ env=None, target='/', retries=None),
416+ mock.call(cmd_prefix + ['dist-upgrade'],
417+ env=None, target='/'),
418+ ]
419+
420+ m_subp.reset_mock()
421+ distro.apt_install('dist-upgrade')
422+ m_subp.assert_has_calls(expected_calls)
423+
424+ expected_dl_cmd = cmd_prefix + ['install', '--download-only', 'git']
425+ expected_inst_cmd = cmd_prefix + ['install', 'git']
426+
427+ m_subp.reset_mock()
428+ distro.apt_install('install', ['git'], download_only=True)
429+ m_subp.assert_called_once_with(expected_dl_cmd, env=None, target='/',
430+ retries=None)
431+
432+ m_subp.reset_mock()
433+ distro.apt_install('install', ['git'], assume_downloaded=True)
434+ m_subp.assert_called_once_with(expected_inst_cmd, env=None, target='/')
435+
436+ @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
437+ @mock.patch('curtin.util.subp')
438+ def test_apt_install_invalid_mode(self, m_subp):
439+ with self.assertRaisesRegex(ValueError, 'Unsupported mode.*'):
440+ distro.apt_install('update')
441+ m_subp.assert_not_called()
442+
443+ @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
444+ @mock.patch('curtin.util.subp')
445+ def test_apt_install_conflict(self, m_subp):
446+ with self.assertRaisesRegex(ValueError, '.*incompatible.*'):
447+ distro.apt_install('install', ['git'],
448+ download_only=True, assume_downloaded=True)
449+ m_subp.assert_not_called()
450+
451+
452 class TestYumInstall(CiTestCase):
453
454 @mock.patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
455@@ -482,9 +584,13 @@ class TestSystemUpgrade(CiTestCase):
456 '--option=Dpkg::options::=--force-unsafe-io',
457 '--option=Dpkg::Options::=--force-confold']
458 apt_cmd = apt_base + ['dist-upgrade'] + pkglist
459+ dl_apt_cmd = apt_base + ['dist-upgrade', '--download-only'] + pkglist
460+ inst_apt_cmd = apt_base + ['dist-upgrade'] + pkglist
461 auto_remove = apt_base + ['autoremove']
462 expected_calls = [
463- mock.call(apt_cmd, env=env, target=paths.target_path(target)),
464+ mock.call(dl_apt_cmd, env=env, retries=None,
465+ target=paths.target_path(target)),
466+ mock.call(inst_apt_cmd, env=env, target=paths.target_path(target)),
467 mock.call(['apt-get', 'clean'], target=paths.target_path(target)),
468 mock.call(auto_remove, env=env, target=paths.target_path(target)),
469 ]

Subscribers

People subscribed via source and target branches