Merge ~ogayot/curtin:retry-download into curtin:master
- Git
- lp:~ogayot/curtin
- retry-download
- Merge into master
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) |
||||
Related bugs: |
|
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-
Server Team CI bot (server-team-bot) wrote : | # |
Server Team CI bot (server-team-bot) wrote : | # |
PASSED: Continuous integration, rev:c75bf1d34e8
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
Click here to trigger a rebuild:
https:/
Michael Hudson-Doyle (mwhudson) wrote : | # |
Seems broadly fine. Are there any docs for these commands to update?
Olivier Gayot (ogayot) wrote : | # |
> Are there any docs for these commands to update?
I could not find any but I might have overlooked something.
Michael Hudson-Doyle (mwhudson) wrote : | # |
Oh well then :-)
Preview Diff
1 | diff --git a/curtin/commands/__init__.py b/curtin/commands/__init__.py |
2 | index 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 |
38 | diff --git a/curtin/commands/system_install.py b/curtin/commands/system_install.py |
39 | index 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': '+'}), |
96 | diff --git a/curtin/commands/system_upgrade.py b/curtin/commands/system_upgrade.py |
97 | index 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 | |
122 | diff --git a/curtin/distro.py b/curtin/distro.py |
123 | index 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): |
335 | diff --git a/tests/unittests/test_distro.py b/tests/unittests/test_distro.py |
336 | index 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 | ] |
FAILED: Continuous integration, rev:d267ca63445 2869b410b00f458 5f8dbe3ba050c4 /jenkins. canonical. com/server- team/job/ curtin- ci/83/ /jenkins. canonical. com/server- team/job/ curtin- ci/nodes= metal-amd64/ 83/ /jenkins. canonical. com/server- team/job/ curtin- ci/nodes= metal-arm64/ 83/ /jenkins. canonical. com/server- team/job/ curtin- ci/nodes= metal-ppc64el/ 83/ /jenkins. canonical. com/server- team/job/ curtin- ci/nodes= metal-s390x/ 83/
https:/
Executed test runs:
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild: /jenkins. canonical. com/server- team/job/ curtin- ci/83// rebuild
https:/