ansible-core 2.17.13rc1__py3-none-any.whl → 2.17.14rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ansible-core might be problematic. Click here for more details.

@@ -17,6 +17,6 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = '2.17.13rc1'
20
+ __version__ = '2.17.14rc1'
21
21
  __author__ = 'Ansible, Inc.'
22
22
  __codename__ = "Gallows Pole"
ansible/release.py CHANGED
@@ -17,6 +17,6 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = '2.17.13rc1'
20
+ __version__ = '2.17.14rc1'
21
21
  __author__ = 'Ansible, Inc.'
22
22
  __codename__ = "Gallows Pole"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ansible-core
3
- Version: 2.17.13rc1
3
+ Version: 2.17.14rc1
4
4
  Summary: Radically simple IT automation
5
5
  Home-page: https://ansible.com/
6
6
  Author: Ansible, Inc.
@@ -3,7 +3,7 @@ ansible/__main__.py,sha256=EnLcULXNtSXkuJ8igEHPPLBTZKAwqXv4PvMEhvzp2Oo,1430
3
3
  ansible/constants.py,sha256=vRwEcoynqtuKDPKsxKUY94XzrTSV3J0y1slb907DioU,9140
4
4
  ansible/context.py,sha256=oKYyfjfWpy8vDeProtqfnqSmuij_t75_5e5t0U_hQ1g,1933
5
5
  ansible/keyword_desc.yml,sha256=vE9joFgSeHR4Djl7Bd-HHVCrGByRCrTUmWYZ8LKPZKk,7412
6
- ansible/release.py,sha256=Q-TERToixG2swT8doxEg0dJU0XnVhrRi-6iNjeizmLk,836
6
+ ansible/release.py,sha256=uy2FZVAk8RGRrhMFnOC8NXgkrzn3OxSZCCtWww_PpTk,836
7
7
  ansible/_vendor/__init__.py,sha256=2QBeBwT7uG7M3Aw-pIdCpt6XPtHMCpbEKfACYKA7xIg,2033
8
8
  ansible/cli/__init__.py,sha256=fzgR82NIGBH3GujIMehhAaP4KYszn4uztuCaFYRUpGk,28718
9
9
  ansible/cli/adhoc.py,sha256=quJ9WzRzf3dz_dtDGmahNMffqyNVy1jzQCMo21YL5Qg,8194
@@ -140,7 +140,7 @@ ansible/inventory/host.py,sha256=PDb5OTplhfpUIvdHiP2BckUOB1gUl302N-3sW0_sTyg,503
140
140
  ansible/inventory/manager.py,sha256=45mHgZTAkQ3IjAtrgsNzJXvynC-HIEor-JJE-V3xXN4,29454
141
141
  ansible/module_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
142
142
  ansible/module_utils/_text.py,sha256=VkWgAnSNVCbTQqZgllUObBFsH3uM4EUW5srl1UR9t1g,544
143
- ansible/module_utils/ansible_release.py,sha256=Q-TERToixG2swT8doxEg0dJU0XnVhrRi-6iNjeizmLk,836
143
+ ansible/module_utils/ansible_release.py,sha256=uy2FZVAk8RGRrhMFnOC8NXgkrzn3OxSZCCtWww_PpTk,836
144
144
  ansible/module_utils/api.py,sha256=DWIuLW5gDWuyyDHLLgGnub42Qa8kagDdkf1xDeLAFl4,5784
145
145
  ansible/module_utils/basic.py,sha256=UcDamm_6bkL3HXxKvQcSUlzDOHkIlvd8AYGuqJNmZeI,86113
146
146
  ansible/module_utils/connection.py,sha256=q_BdUaST6E44ltHsWPOFOheXK9vKmzaJvP-eQOrOrmE,8394
@@ -678,14 +678,14 @@ ansible/vars/hostvars.py,sha256=o11xrzDVYn23renGbb3lx3R-nH9qOjLFju5IYJanDxg,5324
678
678
  ansible/vars/manager.py,sha256=Yuo51lu4UVfzxMS63zYtZMcI8iFYgLXtg0p8fnq3Y7E,38871
679
679
  ansible/vars/plugins.py,sha256=RsRU9fiLcJwPIAyTYnmVZglsiEOMCIgQskflavE-XnE,4546
680
680
  ansible/vars/reserved.py,sha256=Tsc4m2UwVce3dOvSWrjT2wB3lpNJtUyNZn45zNhsW0I,2869
681
- ansible_core-2.17.13rc1.data/scripts/ansible-test,sha256=dyY2HtRZotRQO3b89HGXY_KnJgBvgsm4eLIe4B2LUoA,1637
682
- ansible_core-2.17.13rc1.dist-info/licenses/COPYING,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
681
+ ansible_core-2.17.14rc1.data/scripts/ansible-test,sha256=dyY2HtRZotRQO3b89HGXY_KnJgBvgsm4eLIe4B2LUoA,1637
682
+ ansible_core-2.17.14rc1.dist-info/licenses/COPYING,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
683
683
  ansible_test/__init__.py,sha256=20VPOj11c6Ut1Av9RaurgwJvFhMqkWG3vAvcCbecNKw,66
684
684
  ansible_test/_data/ansible.cfg,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
685
685
  ansible_test/_data/coveragerc,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
686
686
  ansible_test/_data/completion/docker.txt,sha256=ddsWorTETn1pF9n5coT-tVRC1Hizf9vp6_q0t28S3I0,642
687
687
  ansible_test/_data/completion/network.txt,sha256=BxVN0UxlVkRUrPi9MBArQOe6nR8exaow0oCAznUdfKQ,100
688
- ansible_test/_data/completion/remote.txt,sha256=cEpnoSjuxrIk8rgxRninhOjtWrYJ3UergVZA29Xibfk,918
688
+ ansible_test/_data/completion/remote.txt,sha256=Diq2dsppMJCspO6m_eVJy3xdSKHCQwgoCVauyfIVtbw,914
689
689
  ansible_test/_data/completion/windows.txt,sha256=LunFLE7xMeoS9TVDuE58nUBVzsz-Wh-9wfL80mGiUmo,147
690
690
  ansible_test/_data/playbooks/posix_coverage_setup.yml,sha256=PgQNVzVTsNmfnu0sT2SAYiWtkMSOppfmh0oVmAsb7TQ,594
691
691
  ansible_test/_data/playbooks/posix_coverage_teardown.yml,sha256=xHci5QllwJymFtig-hsOXm-Wdrxz063JH14aIyRXhyc,212
@@ -741,7 +741,7 @@ ansible_test/_internal/connections.py,sha256=-gK9FqvmpsjENdYNkvWgFgqYHJSS_F2XkvQ
741
741
  ansible_test/_internal/constants.py,sha256=djMgWI_xR1Yg6M9Au8dEtao6yTYIzeLA-Ctxb1sKnHg,2056
742
742
  ansible_test/_internal/containers.py,sha256=8uRbrDtQKJznPYHbrCDuxZI0teyhcL8qT3mAO2M_DU8,33905
743
743
  ansible_test/_internal/content_config.py,sha256=QKR_XVBgYRNZL-XawF2pN2ERTZ6lSm1AJg9ZQRD6IHE,5588
744
- ansible_test/_internal/core_ci.py,sha256=pyiwFG_TgDSQw34qW-PG8T2VYS6XxiF0zOEWGYXRRek,17309
744
+ ansible_test/_internal/core_ci.py,sha256=9RQU92QfSlEPcf9Wet8c3UWmZGrkmZCMQrT1h51iQuk,17976
745
745
  ansible_test/_internal/coverage_util.py,sha256=p8zcoN6DyyNcLWHzAOtGMeN_6BHTCD1jR9Hm-g-IPIY,9332
746
746
  ansible_test/_internal/data.py,sha256=OFDpRa47yqBqQO1aSvTZVQQpScHvBHsr861586MQEUI,11184
747
747
  ansible_test/_internal/delegation.py,sha256=D8hluDQf_YN3DtVG_8HW0iumRBY3gjp_zP-rlc3VNY4,13418
@@ -751,7 +751,7 @@ ansible_test/_internal/encoding.py,sha256=E61EfXbQw0uQoFhbN3SYx3Oy_1tAMCPAAvY9hk
751
751
  ansible_test/_internal/executor.py,sha256=KW5yI-f-giErQ077MTj707fTtFkf_Kr8IV_Nr36NNmc,2959
752
752
  ansible_test/_internal/git.py,sha256=njtciWq2DlzZ1DAkQi08HRRP-TgH0mgeGZsWcsJGctI,4366
753
753
  ansible_test/_internal/host_configs.py,sha256=0S6EfSE2QMkOi4-ySxM6A4hlGxfb3aSjJKUHOC4wiwM,18283
754
- ansible_test/_internal/host_profiles.py,sha256=vvkstqitZwxE1TCShJmsot_trcmWl45TyG5sBazpFrE,65489
754
+ ansible_test/_internal/host_profiles.py,sha256=auM8hVRk-1lxNlM1llz6BlOJv5lft2dfBfO1kFnt1Iw,65581
755
755
  ansible_test/_internal/http.py,sha256=ENuIPnBXIuvgDSxC-r5eOxfGzscxB6MOVJzT4OQXQSA,3864
756
756
  ansible_test/_internal/init.py,sha256=f2ZN7F-FyjMgN73SUgxwbVtWNhkJv7BIlZ-q4ALHyjM,505
757
757
  ansible_test/_internal/inventory.py,sha256=c79s-xc1uv2nD7rPISv0JKkKspY-X2-kHoozF2R4e1Q,5408
@@ -760,7 +760,7 @@ ansible_test/_internal/junit_xml.py,sha256=5op7cjGK7Et0OSjcAAuUEqNWNAv5ZoNI0rkLx
760
760
  ansible_test/_internal/locale_util.py,sha256=tjRbwKmgMQc1ysIhvP8yBhFcNA-2UCaWfQBDgrRFUxU,2161
761
761
  ansible_test/_internal/metadata.py,sha256=c9ThXPUlgeKYhaTUmfCSS4INRNQ1JhN2KEOVaX3m1Gk,4791
762
762
  ansible_test/_internal/payload.py,sha256=1Pw05OEHvP3LMQnoLXch8631c94YMklWlpDn0CvQECw,8012
763
- ansible_test/_internal/provisioning.py,sha256=9Zl3xQqljx0MGDTp55Q4LZPWQ7Afj5K87cGsXzPGS5Y,7320
763
+ ansible_test/_internal/provisioning.py,sha256=owGvyyBmMjtdAw0x41X9qYtOJnkvskpQRcpJ-pmLwlo,7409
764
764
  ansible_test/_internal/pypi_proxy.py,sha256=1y21FjIyzXMdbFFWiOQWr3BocxXTsavw_NCagSkD0uM,6019
765
765
  ansible_test/_internal/python_requirements.py,sha256=tilVPxEthIWBYd7PGx89cVyYX_Ahy9CVxlJ10PfkzUU,15672
766
766
  ansible_test/_internal/ssh.py,sha256=WeVvn3ReHmjg6Im5BdSBRl1YIj1lOmi71jO9T5fTkik,10781
@@ -768,12 +768,12 @@ ansible_test/_internal/target.py,sha256=Whtb_n0jn4zbiMmX7je5jewgzsRczfXRm_ndYtjT
768
768
  ansible_test/_internal/test.py,sha256=znQmGjKACqDU8T0EAPqcv2qyy0J7M2w4OmyYhwHLqT0,14515
769
769
  ansible_test/_internal/thread.py,sha256=WQoZ2q2ljmEkKHRDkIqwxW7eZbkCKDrG3YZfcaxHzHw,2596
770
770
  ansible_test/_internal/timeout.py,sha256=hT-LirImhAh1iCGIh8JpmECXsiGu6Zetw8BWl1iBIC8,4050
771
- ansible_test/_internal/util.py,sha256=1_yTfH0t0dfa77EZvY1wnkQ6rgTdSRUQL9oCuRWrD00,37832
771
+ ansible_test/_internal/util.py,sha256=dkDOAbm3e9IxvMDTta23hCxlca3dpq10pVP2QK-clmc,37859
772
772
  ansible_test/_internal/util_common.py,sha256=W5mkR0sevcyMWsMPYcpxRN-b8It8N9g6PqkophHCI9U,17385
773
773
  ansible_test/_internal/venv.py,sha256=k7L9_Ocpsdwp4kQFLF59BVguymd2nqJ-bLHH1NlMET0,5521
774
- ansible_test/_internal/ci/__init__.py,sha256=QOaC_8_wUzqFEbsFCXYAnElWoUo6gB40CXvP9RJ-Iyo,7738
775
- ansible_test/_internal/ci/azp.py,sha256=YTTDiAX26kskOP2RSZtu_bIQKIK_grMbGesOsS_55QA,10137
776
- ansible_test/_internal/ci/local.py,sha256=E4nnerMKdBoVEbsT8IBkl0nSdXyxO2gT8WAaxyzA1EY,6739
774
+ ansible_test/_internal/ci/__init__.py,sha256=wFAyQVsPJbHEI94EN3F3yLBP8aZ4Zlm4U1jka0U8Z8M,4683
775
+ ansible_test/_internal/ci/azp.py,sha256=i2Th7ZWJhefYR2oxcsK8ZlU11xXtMbCJBjBQa3kAj_8,10104
776
+ ansible_test/_internal/ci/local.py,sha256=Yd2fqBs9KcbnF-TEx9PyEslCNF69WNUlM5Bm3-vzq9c,9905
777
777
  ansible_test/_internal/classification/__init__.py,sha256=ZhYq3YHtd5iO8yFWcnWqwg_JIGWOYHmFoxwoUzKrZmM,34199
778
778
  ansible_test/_internal/classification/common.py,sha256=jd5VLRegcOX-GNTZqN_7PBzwKF6akFQYsPEltfynGtU,894
779
779
  ansible_test/_internal/classification/csharp.py,sha256=3QpVZjamTTG7h86oeVm7d4UMbyojPbBALHVqCpxS1ic,3241
@@ -959,7 +959,7 @@ ansible_test/_util/target/pytest/plugins/ansible_pytest_collections.py,sha256=vn
959
959
  ansible_test/_util/target/pytest/plugins/ansible_pytest_coverage.py,sha256=RAEMJ4N88UhwlCcD3gRG78ERb12uW2FMYI4j1tOiEhU,1546
960
960
  ansible_test/_util/target/sanity/compile/compile.py,sha256=iTRgiZHNO8DwjSqHBw8gPBbFtWnr-Zbd_ybymeazdtA,1302
961
961
  ansible_test/_util/target/sanity/import/importer.py,sha256=BLQN6NmdaMgbI6mu_AdkL4AeD5LxYUi-JXEBGJTuhnU,25148
962
- ansible_test/_util/target/setup/bootstrap.sh,sha256=X95GFGRuW9cPBMUgZDH4L_0QMWnrOoqBjCRl0L3rt3M,13299
962
+ ansible_test/_util/target/setup/bootstrap.sh,sha256=ZXMU6ps2bfZYnumw7OZ-4Zbr7erVsBf6qqSb95ZGsOo,12848
963
963
  ansible_test/_util/target/setup/check_systemd_cgroup_v1.sh,sha256=Aq0T62x_KLtkGaWzYqWjvhchTqYFflrTbQET3h6xrT0,395
964
964
  ansible_test/_util/target/setup/probe_cgroups.py,sha256=wUHvjW_GXpcyMGw308w26T09cOtBW5EU7i9WagGDQ7o,659
965
965
  ansible_test/_util/target/setup/quiet_pip.py,sha256=d3bvh9k2XI_z8-vb3ZoI4lwL8LaFkwvjJE7PpApBlcw,1979
@@ -980,8 +980,8 @@ ansible_test/config/cloud-config-vultr.ini.template,sha256=XLKHk3lg_8ReQMdWfZzhh
980
980
  ansible_test/config/config.yml,sha256=wb3knoBmZewG3GWOMnRHoVPQWW4vPixKLPMNS6vJmTc,2620
981
981
  ansible_test/config/inventory.networking.template,sha256=bFNSk8zNQOaZ_twaflrY0XZ9mLwUbRLuNT0BdIFwvn4,1335
982
982
  ansible_test/config/inventory.winrm.template,sha256=1QU8W-GFLnYEw8yY9bVIvUAVvJYPM3hyoijf6-M7T00,1098
983
- ansible_core-2.17.13rc1.dist-info/METADATA,sha256=g1dSnlWTSzMpDIuKRw4a-nUOCVnCiqVlGzL-8GYLIPM,6991
984
- ansible_core-2.17.13rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
985
- ansible_core-2.17.13rc1.dist-info/entry_points.txt,sha256=0mpmsrIhODChxKl3eS-NcVQCaMetBn8KdPLtVxQgR64,453
986
- ansible_core-2.17.13rc1.dist-info/top_level.txt,sha256=IFbRLjAvih1DYzJWg3_F6t4sCzEMxRO7TOMNs6GkYHo,21
987
- ansible_core-2.17.13rc1.dist-info/RECORD,,
983
+ ansible_core-2.17.14rc1.dist-info/METADATA,sha256=gQ_JZpOu3czuT-G_edIR9-z4GPZRlOREJsByiU_IQqM,6991
984
+ ansible_core-2.17.14rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
985
+ ansible_core-2.17.14rc1.dist-info/entry_points.txt,sha256=0mpmsrIhODChxKl3eS-NcVQCaMetBn8KdPLtVxQgR64,453
986
+ ansible_core-2.17.14rc1.dist-info/top_level.txt,sha256=IFbRLjAvih1DYzJWg3_F6t4sCzEMxRO7TOMNs6GkYHo,21
987
+ ansible_core-2.17.14rc1.dist-info/RECORD,,
@@ -2,7 +2,7 @@ alpine/3.19 python=3.11 become=doas_sudo provider=aws arch=x86_64
2
2
  alpine become=doas_sudo provider=aws arch=x86_64
3
3
  fedora/39 python=3.12 become=sudo provider=aws arch=x86_64
4
4
  fedora become=sudo provider=aws arch=x86_64
5
- freebsd/13.3 python=3.9,3.11 python_dir=/usr/local/bin become=su_sudo provider=aws arch=x86_64
5
+ freebsd/13.5 python=3.11 python_dir=/usr/local/bin become=su_sudo provider=aws arch=x86_64
6
6
  freebsd/14.1 python=3.9,3.11 python_dir=/usr/local/bin become=su_sudo provider=aws arch=x86_64
7
7
  freebsd python_dir=/usr/local/bin become=su_sudo provider=aws arch=x86_64
8
8
  macos/14.3 python=3.11 python_dir=/usr/local/bin become=sudo provider=parallels arch=x86_64
@@ -2,22 +2,13 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import abc
5
- import base64
5
+ import dataclasses
6
+ import datetime
6
7
  import json
7
- import os
8
+ import pathlib
8
9
  import tempfile
9
10
  import typing as t
10
11
 
11
- from ..encoding import (
12
- to_bytes,
13
- to_text,
14
- )
15
-
16
- from ..io import (
17
- read_text_file,
18
- write_text_file,
19
- )
20
-
21
12
  from ..config import (
22
13
  CommonConfig,
23
14
  TestConfig,
@@ -33,6 +24,65 @@ from ..util import (
33
24
  )
34
25
 
35
26
 
27
+ @dataclasses.dataclass(frozen=True, kw_only=True)
28
+ class AuthContext:
29
+ """Information about the request to which authentication will be applied."""
30
+
31
+ stage: str
32
+ provider: str
33
+ request_id: str
34
+
35
+
36
+ class AuthHelper:
37
+ """Authentication helper."""
38
+
39
+ NAMESPACE: t.ClassVar = 'ci@core.ansible.com'
40
+
41
+ def __init__(self, key_file: pathlib.Path) -> None:
42
+ self.private_key_file = pathlib.Path(str(key_file).removesuffix('.pub'))
43
+ self.public_key_file = pathlib.Path(f'{self.private_key_file}.pub')
44
+
45
+ def sign_request(self, request: dict[str, object], context: AuthContext) -> None:
46
+ """Sign the given auth request using the provided context."""
47
+ request.update(
48
+ stage=context.stage,
49
+ provider=context.provider,
50
+ request_id=context.request_id,
51
+ timestamp=datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat(),
52
+ )
53
+
54
+ with tempfile.TemporaryDirectory() as temp_dir:
55
+ payload_path = pathlib.Path(temp_dir) / 'auth.json'
56
+ payload_path.write_text(json.dumps(request, sort_keys=True))
57
+
58
+ cmd = ['ssh-keygen', '-q', '-Y', 'sign', '-f', str(self.private_key_file), '-n', self.NAMESPACE, str(payload_path)]
59
+ raw_command(cmd, capture=False, interactive=True)
60
+
61
+ signature_path = pathlib.Path(f'{payload_path}.sig')
62
+ signature = signature_path.read_text()
63
+
64
+ request.update(signature=signature)
65
+
66
+
67
+ class GeneratingAuthHelper(AuthHelper, metaclass=abc.ABCMeta):
68
+ """Authentication helper which generates a key pair on demand."""
69
+
70
+ def __init__(self) -> None:
71
+ super().__init__(pathlib.Path('~/.ansible/test/ansible-core-ci').expanduser())
72
+
73
+ def sign_request(self, request: dict[str, object], context: AuthContext) -> None:
74
+ if not self.private_key_file.exists():
75
+ self.generate_key_pair()
76
+
77
+ super().sign_request(request, context)
78
+
79
+ def generate_key_pair(self) -> None:
80
+ """Generate key pair."""
81
+ self.private_key_file.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ raw_command(['ssh-keygen', '-q', '-f', str(self.private_key_file), '-N', ''], capture=True)
84
+
85
+
36
86
  class ChangeDetectionNotSupported(ApplicationError):
37
87
  """Exception for cases where change detection is not supported."""
38
88
 
@@ -74,8 +124,8 @@ class CIProvider(metaclass=abc.ABCMeta):
74
124
  """Return True if Ansible Core CI is supported."""
75
125
 
76
126
  @abc.abstractmethod
77
- def prepare_core_ci_auth(self) -> dict[str, t.Any]:
78
- """Return authentication details for Ansible Core CI."""
127
+ def prepare_core_ci_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]:
128
+ """Prepare an Ansible Core CI request using the given config and context."""
79
129
 
80
130
  @abc.abstractmethod
81
131
  def get_git_details(self, args: CommonConfig) -> t.Optional[dict[str, t.Any]]:
@@ -100,119 +150,3 @@ def get_ci_provider() -> CIProvider:
100
150
  display.info('Detected CI provider: %s' % provider.name)
101
151
 
102
152
  return provider
103
-
104
-
105
- class AuthHelper(metaclass=abc.ABCMeta):
106
- """Public key based authentication helper for Ansible Core CI."""
107
-
108
- def sign_request(self, request: dict[str, t.Any]) -> None:
109
- """Sign the given auth request and make the public key available."""
110
- payload_bytes = to_bytes(json.dumps(request, sort_keys=True))
111
- signature_raw_bytes = self.sign_bytes(payload_bytes)
112
- signature = to_text(base64.b64encode(signature_raw_bytes))
113
-
114
- request.update(signature=signature)
115
-
116
- def initialize_private_key(self) -> str:
117
- """
118
- Initialize and publish a new key pair (if needed) and return the private key.
119
- The private key is cached across ansible-test invocations, so it is only generated and published once per CI job.
120
- """
121
- path = os.path.expanduser('~/.ansible-core-ci-private.key')
122
-
123
- if os.path.exists(to_bytes(path)):
124
- private_key_pem = read_text_file(path)
125
- else:
126
- private_key_pem = self.generate_private_key()
127
- write_text_file(path, private_key_pem)
128
-
129
- return private_key_pem
130
-
131
- @abc.abstractmethod
132
- def sign_bytes(self, payload_bytes: bytes) -> bytes:
133
- """Sign the given payload and return the signature, initializing a new key pair if required."""
134
-
135
- @abc.abstractmethod
136
- def publish_public_key(self, public_key_pem: str) -> None:
137
- """Publish the given public key."""
138
-
139
- @abc.abstractmethod
140
- def generate_private_key(self) -> str:
141
- """Generate a new key pair, publishing the public key and returning the private key."""
142
-
143
-
144
- class CryptographyAuthHelper(AuthHelper, metaclass=abc.ABCMeta):
145
- """Cryptography based public key based authentication helper for Ansible Core CI."""
146
-
147
- def sign_bytes(self, payload_bytes: bytes) -> bytes:
148
- """Sign the given payload and return the signature, initializing a new key pair if required."""
149
- # import cryptography here to avoid overhead and failures in environments which do not use/provide it
150
- from cryptography.hazmat.backends import default_backend
151
- from cryptography.hazmat.primitives import hashes
152
- from cryptography.hazmat.primitives.asymmetric import ec
153
- from cryptography.hazmat.primitives.serialization import load_pem_private_key
154
-
155
- private_key_pem = self.initialize_private_key()
156
- private_key = load_pem_private_key(to_bytes(private_key_pem), None, default_backend())
157
-
158
- assert isinstance(private_key, ec.EllipticCurvePrivateKey)
159
-
160
- signature_raw_bytes = private_key.sign(payload_bytes, ec.ECDSA(hashes.SHA256()))
161
-
162
- return signature_raw_bytes
163
-
164
- def generate_private_key(self) -> str:
165
- """Generate a new key pair, publishing the public key and returning the private key."""
166
- # import cryptography here to avoid overhead and failures in environments which do not use/provide it
167
- from cryptography.hazmat.backends import default_backend
168
- from cryptography.hazmat.primitives import serialization
169
- from cryptography.hazmat.primitives.asymmetric import ec
170
-
171
- private_key = ec.generate_private_key(ec.SECP384R1(), default_backend())
172
- public_key = private_key.public_key()
173
-
174
- private_key_pem = to_text(private_key.private_bytes( # type: ignore[attr-defined] # documented method, but missing from type stubs
175
- encoding=serialization.Encoding.PEM,
176
- format=serialization.PrivateFormat.PKCS8,
177
- encryption_algorithm=serialization.NoEncryption(),
178
- ))
179
-
180
- public_key_pem = to_text(public_key.public_bytes(
181
- encoding=serialization.Encoding.PEM,
182
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
183
- ))
184
-
185
- self.publish_public_key(public_key_pem)
186
-
187
- return private_key_pem
188
-
189
-
190
- class OpenSSLAuthHelper(AuthHelper, metaclass=abc.ABCMeta):
191
- """OpenSSL based public key based authentication helper for Ansible Core CI."""
192
-
193
- def sign_bytes(self, payload_bytes: bytes) -> bytes:
194
- """Sign the given payload and return the signature, initializing a new key pair if required."""
195
- private_key_pem = self.initialize_private_key()
196
-
197
- with tempfile.NamedTemporaryFile() as private_key_file:
198
- private_key_file.write(to_bytes(private_key_pem))
199
- private_key_file.flush()
200
-
201
- with tempfile.NamedTemporaryFile() as payload_file:
202
- payload_file.write(payload_bytes)
203
- payload_file.flush()
204
-
205
- with tempfile.NamedTemporaryFile() as signature_file:
206
- raw_command(['openssl', 'dgst', '-sha256', '-sign', private_key_file.name, '-out', signature_file.name, payload_file.name], capture=True)
207
- signature_raw_bytes = signature_file.read()
208
-
209
- return signature_raw_bytes
210
-
211
- def generate_private_key(self) -> str:
212
- """Generate a new key pair, publishing the public key and returning the private key."""
213
- private_key_pem = raw_command(['openssl', 'ecparam', '-genkey', '-name', 'secp384r1', '-noout'], capture=True)[0]
214
- public_key_pem = raw_command(['openssl', 'ec', '-pubout'], data=private_key_pem, capture=True)[0]
215
-
216
- self.publish_public_key(public_key_pem)
217
-
218
- return private_key_pem
@@ -30,9 +30,10 @@ from ..util import (
30
30
  )
31
31
 
32
32
  from . import (
33
+ AuthContext,
33
34
  ChangeDetectionNotSupported,
34
35
  CIProvider,
35
- CryptographyAuthHelper,
36
+ GeneratingAuthHelper,
36
37
  )
37
38
 
38
39
  CODE = 'azp'
@@ -111,10 +112,11 @@ class AzurePipelines(CIProvider):
111
112
  """Return True if Ansible Core CI is supported."""
112
113
  return True
113
114
 
114
- def prepare_core_ci_auth(self) -> dict[str, t.Any]:
115
- """Return authentication details for Ansible Core CI."""
115
+ def prepare_core_ci_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]:
116
116
  try:
117
- request = dict(
117
+ request: dict[str, object] = dict(
118
+ type="azp:ssh",
119
+ config=config,
118
120
  org_name=os.environ['SYSTEM_COLLECTIONURI'].strip('/').split('/')[-1],
119
121
  project_name=os.environ['SYSTEM_TEAMPROJECT'],
120
122
  build_id=int(os.environ['BUILD_BUILDID']),
@@ -123,13 +125,9 @@ class AzurePipelines(CIProvider):
123
125
  except KeyError as ex:
124
126
  raise MissingEnvironmentVariable(name=ex.args[0]) from None
125
127
 
126
- self.auth.sign_request(request)
128
+ self.auth.sign_request(request, context)
127
129
 
128
- auth = dict(
129
- azp=request,
130
- )
131
-
132
- return auth
130
+ return request
133
131
 
134
132
  def get_git_details(self, args: CommonConfig) -> t.Optional[dict[str, t.Any]]:
135
133
  """Return details about git in the current environment."""
@@ -143,14 +141,14 @@ class AzurePipelines(CIProvider):
143
141
  return details
144
142
 
145
143
 
146
- class AzurePipelinesAuthHelper(CryptographyAuthHelper):
147
- """
148
- Authentication helper for Azure Pipelines.
149
- Based on cryptography since it is provided by the default Azure Pipelines environment.
150
- """
144
+ class AzurePipelinesAuthHelper(GeneratingAuthHelper):
145
+ """Authentication helper for Azure Pipelines."""
146
+
147
+ def generate_key_pair(self) -> None:
148
+ super().generate_key_pair()
149
+
150
+ public_key_pem = self.public_key_file.read_text()
151
151
 
152
- def publish_public_key(self, public_key_pem: str) -> None:
153
- """Publish the given public key."""
154
152
  try:
155
153
  agent_temp_directory = os.environ['AGENT_TEMPDIRECTORY']
156
154
  except KeyError as ex:
@@ -1,10 +1,12 @@
1
1
  """Support code for working without a supported CI provider."""
2
2
  from __future__ import annotations
3
3
 
4
- import os
4
+ import abc
5
+ import inspect
5
6
  import platform
6
7
  import random
7
8
  import re
9
+ import pathlib
8
10
  import typing as t
9
11
 
10
12
  from ..config import (
@@ -23,11 +25,14 @@ from ..git import (
23
25
  from ..util import (
24
26
  ApplicationError,
25
27
  display,
28
+ get_subclasses,
26
29
  is_binary_file,
27
30
  SubprocessError,
28
31
  )
29
32
 
30
33
  from . import (
34
+ AuthContext,
35
+ AuthHelper,
31
36
  CIProvider,
32
37
  )
33
38
 
@@ -119,34 +124,20 @@ class Local(CIProvider):
119
124
 
120
125
  def supports_core_ci_auth(self) -> bool:
121
126
  """Return True if Ansible Core CI is supported."""
122
- path = self._get_aci_key_path()
123
- return os.path.exists(path)
127
+ return Authenticator.available()
124
128
 
125
- def prepare_core_ci_auth(self) -> dict[str, t.Any]:
126
- """Return authentication details for Ansible Core CI."""
127
- path = self._get_aci_key_path()
128
- auth_key = read_text_file(path).strip()
129
+ def prepare_core_ci_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]:
130
+ if not (authenticator := Authenticator.load()):
131
+ raise ApplicationError('Ansible Core CI authentication has not been configured.')
129
132
 
130
- request = dict(
131
- key=auth_key,
132
- nonce=None,
133
- )
133
+ display.info(f'Using {authenticator} for Ansible Core CI.', verbosity=1)
134
134
 
135
- auth = dict(
136
- remote=request,
137
- )
138
-
139
- return auth
135
+ return authenticator.prepare_auth_request(config, context)
140
136
 
141
137
  def get_git_details(self, args: CommonConfig) -> t.Optional[dict[str, t.Any]]:
142
138
  """Return details about git in the current environment."""
143
139
  return None # not yet implemented for local
144
140
 
145
- @staticmethod
146
- def _get_aci_key_path() -> str:
147
- path = os.path.expanduser('~/.ansible-core-ci.key')
148
- return path
149
-
150
141
 
151
142
  class InvalidBranch(ApplicationError):
152
143
  """Exception for invalid branch specification."""
@@ -213,3 +204,108 @@ class LocalChanges:
213
204
  return True
214
205
 
215
206
  return False
207
+
208
+
209
+ class Authenticator(metaclass=abc.ABCMeta):
210
+ """Base class for authenticators."""
211
+
212
+ @staticmethod
213
+ def list() -> list[type[Authenticator]]:
214
+ """List all authenticators in priority order."""
215
+ return sorted((sc for sc in get_subclasses(Authenticator) if not inspect.isabstract(sc)), key=lambda obj: obj.priority())
216
+
217
+ @staticmethod
218
+ def load() -> Authenticator | None:
219
+ """Load an authenticator instance, returning None if not configured."""
220
+ for implementation in Authenticator.list():
221
+ if implementation.config_file().exists():
222
+ return implementation()
223
+
224
+ return None
225
+
226
+ @staticmethod
227
+ def available() -> bool:
228
+ """Return True if an authenticator is available, otherwise False."""
229
+ return bool(Authenticator.load())
230
+
231
+ @classmethod
232
+ @abc.abstractmethod
233
+ def priority(cls) -> int:
234
+ """Priority used to determine which authenticator is tried first, from lowest to highest."""
235
+
236
+ @classmethod
237
+ @abc.abstractmethod
238
+ def config_file(cls) -> pathlib.Path:
239
+ """Path to the config file for this authenticator."""
240
+
241
+ @abc.abstractmethod
242
+ def prepare_auth_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]:
243
+ """Prepare an authenticated Ansible Core CI request using the given config and context."""
244
+
245
+ def __str__(self) -> str:
246
+ return self.__class__.__name__
247
+
248
+
249
+ class PasswordAuthenticator(Authenticator):
250
+ """Authenticate using a password."""
251
+
252
+ @classmethod
253
+ def priority(cls) -> int:
254
+ return 200
255
+
256
+ @classmethod
257
+ def config_file(cls) -> pathlib.Path:
258
+ return pathlib.Path('~/.ansible-core-ci.key').expanduser()
259
+
260
+ def prepare_auth_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]:
261
+ parts = self.config_file().read_text().strip().split(maxsplit=1)
262
+
263
+ if len(parts) == 1: # temporary backward compatibility for legacy API keys
264
+ request = dict(
265
+ config=config,
266
+ auth=dict(
267
+ remote=dict(
268
+ key=parts[0],
269
+ ),
270
+ ),
271
+ )
272
+
273
+ return request
274
+
275
+ username, password = parts
276
+
277
+ request = dict(
278
+ type="remote:password",
279
+ config=config,
280
+ username=username,
281
+ password=password,
282
+ )
283
+
284
+ return request
285
+
286
+
287
+ class SshAuthenticator(Authenticator):
288
+ """Authenticate using an SSH key."""
289
+
290
+ @classmethod
291
+ def priority(cls) -> int:
292
+ return 100
293
+
294
+ @classmethod
295
+ def config_file(cls) -> pathlib.Path:
296
+ return pathlib.Path('~/.ansible-core-ci.auth').expanduser()
297
+
298
+ def prepare_auth_request(self, config: dict[str, object], context: AuthContext) -> dict[str, object]:
299
+ parts = self.config_file().read_text().strip().split(maxsplit=1)
300
+ username, key_file = parts
301
+
302
+ request: dict[str, object] = dict(
303
+ type="remote:ssh",
304
+ config=config,
305
+ username=username,
306
+ )
307
+
308
+ auth_helper = AuthHelper(pathlib.Path(key_file).expanduser())
309
+ auth_helper.sign_request(request, context)
310
+
311
+ return request
@@ -41,6 +41,7 @@ from .config import (
41
41
  )
42
42
 
43
43
  from .ci import (
44
+ AuthContext,
44
45
  get_ci_provider,
45
46
  )
46
47
 
@@ -67,6 +68,10 @@ class Resource(metaclass=abc.ABCMeta):
67
68
  def persist(self) -> bool:
68
69
  """True if the resource is persistent, otherwise false."""
69
70
 
71
+ @abc.abstractmethod
72
+ def get_config(self, core_ci: AnsibleCoreCI) -> dict[str, object]:
73
+ """Return the configuration for this resource."""
74
+
70
75
 
71
76
  @dataclasses.dataclass(frozen=True)
72
77
  class VmResource(Resource):
@@ -91,6 +96,16 @@ class VmResource(Resource):
91
96
  """True if the resource is persistent, otherwise false."""
92
97
  return True
93
98
 
99
+ def get_config(self, core_ci: AnsibleCoreCI) -> dict[str, object]:
100
+ """Return the configuration for this resource."""
101
+ return dict(
102
+ type="vm",
103
+ platform=self.platform,
104
+ version=self.version,
105
+ architecture=self.architecture,
106
+ public_key=core_ci.ssh_key.pub_contents,
107
+ )
108
+
94
109
 
95
110
  @dataclasses.dataclass(frozen=True)
96
111
  class CloudResource(Resource):
@@ -111,6 +126,12 @@ class CloudResource(Resource):
111
126
  """True if the resource is persistent, otherwise false."""
112
127
  return False
113
128
 
129
+ def get_config(self, core_ci: AnsibleCoreCI) -> dict[str, object]:
130
+ """Return the configuration for this resource."""
131
+ return dict(
132
+ type="cloud",
133
+ )
134
+
114
135
 
115
136
  class AnsibleCoreCI:
116
137
  """Client for Ansible Core CI services."""
@@ -188,7 +209,7 @@ class AnsibleCoreCI:
188
209
  display.info(f'Skipping started {self.label} instance.', verbosity=1)
189
210
  return None
190
211
 
191
- return self._start(self.ci_provider.prepare_core_ci_auth())
212
+ return self._start()
192
213
 
193
214
  def stop(self) -> None:
194
215
  """Stop instance."""
@@ -287,26 +308,25 @@ class AnsibleCoreCI:
287
308
  def _uri(self) -> str:
288
309
  return f'{self.endpoint}/{self.stage}/{self.provider}/{self.instance_id}'
289
310
 
290
- def _start(self, auth) -> dict[str, t.Any]:
311
+ def _start(self) -> dict[str, t.Any]:
291
312
  """Start instance."""
292
313
  display.info(f'Initializing new {self.label} instance using: {self._uri}', verbosity=1)
293
314
 
294
- data = dict(
295
- config=dict(
296
- platform=self.platform,
297
- version=self.version,
298
- architecture=self.arch,
299
- public_key=self.ssh_key.pub_contents,
300
- )
315
+ config = self.resource.get_config(self)
316
+
317
+ context = AuthContext(
318
+ request_id=self.instance_id,
319
+ stage=self.stage,
320
+ provider=self.provider,
301
321
  )
302
322
 
303
- data.update(auth=auth)
323
+ request = self.ci_provider.prepare_core_ci_request(config, context)
304
324
 
305
325
  headers = {
306
326
  'Content-Type': 'application/json',
307
327
  }
308
328
 
309
- response = self._start_endpoint(data, headers)
329
+ response = self._start_endpoint(request, headers)
310
330
 
311
331
  self.started = True
312
332
  self._save()
@@ -245,6 +245,9 @@ class HostProfile(t.Generic[THostConfig], metaclass=abc.ABCMeta):
245
245
  self.cache: dict[str, t.Any] = {}
246
246
  """Cache that must not be persisted across delegation."""
247
247
 
248
+ def pre_provision(self) -> None:
249
+ """Pre-provision the host profile."""
250
+
248
251
  def provision(self) -> None:
249
252
  """Provision the host before delegation."""
250
253
 
@@ -328,8 +331,8 @@ class RemoteProfile(SshTargetHostProfile[TRemoteConfig], metaclass=abc.ABCMeta):
328
331
  """The saved Ansible Core CI state."""
329
332
  self.state['core_ci'] = value
330
333
 
331
- def provision(self) -> None:
332
- """Provision the host before delegation."""
334
+ def pre_provision(self) -> None:
335
+ """Pre-provision the host before delegation."""
333
336
  self.core_ci = self.create_core_ci(load=True)
334
337
  self.core_ci.start()
335
338
 
@@ -129,6 +129,9 @@ def prepare_profiles(
129
129
 
130
130
  ExitHandler.register(functools.partial(cleanup_profiles, host_state))
131
131
 
132
+ for pre_profile in host_state.profiles:
133
+ pre_profile.pre_provision()
134
+
132
135
  def provision(profile: HostProfile) -> None:
133
136
  """Provision the given profile."""
134
137
  profile.provision()
@@ -638,6 +638,7 @@ def common_environment() -> dict[str, str]:
638
638
  optional = (
639
639
  'LD_LIBRARY_PATH',
640
640
  'SSH_AUTH_SOCK',
641
+ 'SSH_SK_PROVIDER',
641
642
  # MacOS High Sierra Compatibility
642
643
  # http://sealiesoftware.com/blog/archive/2017/6/5/Objective-C_and_fork_in_macOS_1013.html
643
644
  # Example configuration for macOS:
@@ -2,6 +2,24 @@
2
2
 
3
3
  set -eu
4
4
 
5
+ retry_init()
6
+ {
7
+ attempt=0
8
+ }
9
+
10
+ retry_or_fail()
11
+ {
12
+ attempt=$((attempt + 1))
13
+
14
+ if [ $attempt -gt 5 ]; then
15
+ echo "Failed to install packages. Giving up."
16
+ exit 1
17
+ fi
18
+
19
+ echo "Failed to install packages. Sleeping before trying again..."
20
+ sleep 10
21
+ }
22
+
5
23
  remove_externally_managed_marker()
6
24
  {
7
25
  "${python_interpreter}" -c '
@@ -26,13 +44,13 @@ install_ssh_keys()
26
44
  echo "${ssh_private_key}" > "${ssh_private_key_path}"
27
45
 
28
46
  # add public key to authorized_keys
29
- authoried_keys_path="${HOME}/.ssh/authorized_keys"
47
+ authorized_keys_path="${HOME}/.ssh/authorized_keys"
30
48
 
31
49
  # the existing file is overwritten to avoid conflicts (ex: RHEL on EC2 blocks root login)
32
- cat "${public_key_path}" > "${authoried_keys_path}"
33
- chmod 0600 "${authoried_keys_path}"
50
+ cat "${public_key_path}" > "${authorized_keys_path}"
51
+ chmod 0600 "${authorized_keys_path}"
34
52
 
35
- # add localhost's server keys to known_hosts
53
+ # add localhost server keys to known_hosts
36
54
  known_hosts_path="${HOME}/.ssh/known_hosts"
37
55
 
38
56
  for key in /etc/ssh/ssh_host_*_key.pub; do
@@ -64,13 +82,13 @@ install_pip() {
64
82
  ;;
65
83
  esac
66
84
 
85
+ retry_init
67
86
  while true; do
68
87
  curl --silent --show-error "${pip_bootstrap_url}" -o /tmp/get-pip.py && \
69
88
  "${python_interpreter}" /tmp/get-pip.py --disable-pip-version-check --quiet && \
70
89
  rm /tmp/get-pip.py \
71
90
  && break
72
- echo "Failed to install packages. Sleeping before trying again..."
73
- sleep 10
91
+ retry_or_fail
74
92
  done
75
93
  fi
76
94
  }
@@ -99,21 +117,21 @@ bootstrap_remote_alpine()
99
117
  "
100
118
  fi
101
119
 
120
+ retry_init
102
121
  while true; do
103
122
  # shellcheck disable=SC2086
104
123
  apk add -q ${packages} \
105
124
  && break
106
- echo "Failed to install packages. Sleeping before trying again..."
107
- sleep 10
125
+ retry_or_fail
108
126
  done
109
127
 
110
128
  # Upgrade the `libexpat` package to ensure that an upgraded Python (`pyexpat`) continues to work.
129
+ retry_init
111
130
  while true; do
112
131
  # shellcheck disable=SC2086
113
132
  apk upgrade -q libexpat \
114
133
  && break
115
- echo "Failed to upgrade libexpat. Sleeping before trying again..."
116
- sleep 10
134
+ retry_or_fail
117
135
  done
118
136
  }
119
137
 
@@ -138,12 +156,12 @@ bootstrap_remote_fedora()
138
156
  "
139
157
  fi
140
158
 
159
+ retry_init
141
160
  while true; do
142
161
  # shellcheck disable=SC2086
143
162
  dnf install -q -y ${packages} \
144
163
  && break
145
- echo "Failed to install packages. Sleeping before trying again..."
146
- sleep 10
164
+ retry_or_fail
147
165
  done
148
166
  }
149
167
 
@@ -162,22 +180,14 @@ bootstrap_remote_freebsd()
162
180
  if [ "${controller}" ]; then
163
181
  jinja2_pkg="py${python_package_version}-jinja2"
164
182
  cryptography_pkg="py${python_package_version}-cryptography"
165
- pyyaml_pkg="py${python_package_version}-yaml"
183
+ pyyaml_pkg="py${python_package_version}-pyyaml"
166
184
  packaging_pkg="py${python_package_version}-packaging"
167
185
 
168
186
  # Declare platform/python version combinations which do not have supporting OS packages available.
169
187
  # For these combinations ansible-test will use pip to install the requirements instead.
170
188
  case "${platform_version}/${python_version}" in
171
- 13.3/3.9)
172
- # defaults above 'just work'TM
173
- ;;
174
- 13.3/3.11)
175
- jinja2_pkg="" # not available
176
- cryptography_pkg="" # not available
177
- pyyaml_pkg="" # not available
178
- ;;
179
- 14.1/3.9)
180
- # defaults above 'just work'TM
189
+ 13.5/3.11)
190
+ # defaults available
181
191
  ;;
182
192
  14.1/3.11)
183
193
  cryptography_pkg="" # not available
@@ -203,13 +213,13 @@ bootstrap_remote_freebsd()
203
213
  "
204
214
  fi
205
215
 
216
+ retry_init
206
217
  while true; do
207
218
  # shellcheck disable=SC2086
208
219
  env ASSUME_ALWAYS_YES=YES pkg bootstrap && \
209
220
  pkg install -q -y ${packages} \
210
221
  && break
211
- echo "Failed to install packages. Sleeping before trying again..."
212
- sleep 10
222
+ retry_or_fail
213
223
  done
214
224
 
215
225
  install_pip
@@ -290,12 +300,12 @@ bootstrap_remote_rhel_9()
290
300
  "
291
301
  fi
292
302
 
303
+ retry_init
293
304
  while true; do
294
305
  # shellcheck disable=SC2086
295
306
  dnf install -q -y ${packages} \
296
307
  && break
297
- echo "Failed to install packages. Sleeping before trying again..."
298
- sleep 10
308
+ retry_or_fail
299
309
  done
300
310
  }
301
311
 
@@ -320,12 +330,12 @@ bootstrap_remote_rhel_10()
320
330
  "
321
331
  fi
322
332
 
333
+ retry_init
323
334
  while true; do
324
335
  # shellcheck disable=SC2086
325
336
  dnf install -q -y ${packages} \
326
337
  && break
327
- echo "Failed to install packages. Sleeping before trying again..."
328
- sleep 10
338
+ retry_or_fail
329
339
  done
330
340
  }
331
341
 
@@ -376,13 +386,13 @@ bootstrap_remote_ubuntu()
376
386
  "
377
387
  fi
378
388
 
389
+ retry_init
379
390
  while true; do
380
391
  # shellcheck disable=SC2086
381
392
  apt-get update -qq -y && \
382
393
  DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --no-install-recommends ${packages} \
383
394
  && break
384
- echo "Failed to install packages. Sleeping before trying again..."
385
- sleep 10
395
+ retry_or_fail
386
396
  done
387
397
  }
388
398