Skip to content

Commit 207d38f

Browse files
authored
fix: add single retry of ssh-import-id on exit 1 (#6805)
Add a single retry on ssh-import-id to mitigate intermittent errors from remote service avoiding boot failures. Seeing 10-20% failure rate of exit 1's from ssh-import-id during instance launches in github action runners.
1 parent c7ae6a4 commit 207d38f

2 files changed

Lines changed: 64 additions & 16 deletions

File tree

cloudinit/config/cc_ssh_import_id.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import logging
1111
import pwd
12+
import time
13+
from contextlib import suppress
1214

1315
from cloudinit import subp, util
1416
from cloudinit.cloud import Cloud
@@ -148,13 +150,16 @@ def import_ssh_ids(ids, user):
148150
else:
149151
LOG.error("Neither sudo nor doas available! Unable to import SSH ids.")
150152
return
151-
LOG.debug("Importing SSH ids for user %s.", user)
153+
retry_ssh_import(cmd, 0.5)
152154

153-
try:
155+
156+
def retry_ssh_import(cmd: list, delay: float) -> None:
157+
"""Retry ssh-import-id once if it exits in error."""
158+
with suppress(subp.ProcessExecutionError):
154159
subp.subp(cmd, capture=False)
155-
except subp.ProcessExecutionError as exc:
156-
util.logexc(LOG, "Failed to run command to import %s SSH ids", user)
157-
raise exc
160+
return
161+
time.sleep(delay)
162+
subp.subp(cmd, capture=False)
158163

159164

160165
def is_key_in_nested_dict(config: dict, search_key: str) -> bool:

tests/unittests/config/test_cc_ssh_import_id.py

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import pytest
77

88
from cloudinit.config import cc_ssh_import_id
9+
from cloudinit.subp import ProcessExecutionError
910
from tests.unittests.util import get_cloud
1011

1112
LOG = logging.getLogger(__name__)
1213

13-
MODPATH = "cloudinit.config.cc_ssh_import_ids."
14+
MODPATH = "cloudinit.config.cc_ssh_import_id."
1415

1516

1617
class TestIsKeyInNestedDict:
@@ -69,17 +70,17 @@ class TestHandleSshImportIDs:
6970
({"ssh_import_id": ["bobkey"]}, "ssh-import-id is not installed"),
7071
),
7172
)
72-
@mock.patch("cloudinit.subp.which")
73+
@mock.patch(MODPATH + "subp.which")
7374
def test_skip_inapplicable_configs(self, m_which, cfg, log, caplog):
7475
"""Skip config without ssh_import_id"""
7576
m_which.return_value = None
7677
cloud = get_cloud("ubuntu")
7778
cc_ssh_import_id.handle("name", cfg, cloud, [])
7879
assert log in caplog.text
7980

80-
@mock.patch("cloudinit.ssh_util.pwd.getpwnam")
81-
@mock.patch("cloudinit.config.cc_ssh_import_id.subp.subp")
82-
@mock.patch("cloudinit.subp.which")
81+
@mock.patch(MODPATH + "pwd.getpwnam")
82+
@mock.patch(MODPATH + "subp.subp")
83+
@mock.patch(MODPATH + "subp.which")
8384
def test_use_sudo(self, m_which, m_subp, m_getpwnam):
8485
"""Check that sudo is available and use that"""
8586
m_which.return_value = "/usr/bin/ssh-import-id"
@@ -98,9 +99,9 @@ def test_use_sudo(self, m_which, m_subp, m_getpwnam):
9899
capture=False,
99100
)
100101

101-
@mock.patch("cloudinit.ssh_util.pwd.getpwnam")
102-
@mock.patch("cloudinit.config.cc_ssh_import_id.subp.subp")
103-
@mock.patch("cloudinit.subp.which")
102+
@mock.patch(MODPATH + "pwd.getpwnam")
103+
@mock.patch(MODPATH + "subp.subp")
104+
@mock.patch(MODPATH + "subp.which")
104105
def test_use_doas(self, m_which, m_subp, m_getpwnam):
105106
"""Check that doas is available and use that"""
106107
m_which.side_effect = [None, "/usr/bin/doas"]
@@ -111,9 +112,9 @@ def test_use_doas(self, m_which, m_subp, m_getpwnam):
111112
["doas", "-u", user, "ssh-import-id"] + ids, capture=False
112113
)
113114

114-
@mock.patch("cloudinit.ssh_util.pwd.getpwnam")
115-
@mock.patch("cloudinit.config.cc_ssh_import_id.subp.subp")
116-
@mock.patch("cloudinit.subp.which")
115+
@mock.patch(MODPATH + "pwd.getpwnam")
116+
@mock.patch(MODPATH + "subp.subp")
117+
@mock.patch(MODPATH + "subp.which")
117118
def test_use_neither_sudo_nor_doas(
118119
self, m_which, m_subp, m_getpwnam, caplog
119120
):
@@ -125,3 +126,45 @@ def test_use_neither_sudo_nor_doas(
125126
assert (
126127
"Neither sudo nor doas available! Unable to import SSH ids"
127128
) in caplog.text
129+
130+
@mock.patch(MODPATH + "time.sleep")
131+
@mock.patch(MODPATH + "pwd.getpwnam")
132+
@mock.patch(MODPATH + "subp.which")
133+
def test_retry_once_on_exit_code_1(
134+
self, m_which, m_getpwnam, m_sleep, mocker
135+
):
136+
"""Only attempt one retry when ssh-import-id exits with code 1."""
137+
m_subp = mocker.patch(
138+
"cloudinit.config.cc_ssh_import_id.subp.subp",
139+
side_effect=[
140+
ProcessExecutionError(exit_code=1, stderr="try1"),
141+
ProcessExecutionError(exit_code=1, stderr="try2"),
142+
],
143+
)
144+
m_which.return_value = "/usr/bin/ssh-import-id"
145+
with pytest.raises(
146+
ProcessExecutionError,
147+
match=r"(?s)Unexpected error while running command.*try2",
148+
):
149+
cc_ssh_import_id.import_ssh_ids(["waffle"], "bob")
150+
151+
assert m_subp.call_count == 2
152+
assert m_sleep.call_count == 1
153+
154+
@mock.patch(MODPATH + "time.sleep")
155+
@mock.patch(MODPATH + "pwd.getpwnam")
156+
@mock.patch(MODPATH + "subp.which")
157+
def test_retry_with_success(self, m_which, m_getpwnam, m_sleep, mocker):
158+
"""Retry succeeds on ssh-import-id with a retry."""
159+
m_subp = mocker.patch(
160+
"cloudinit.config.cc_ssh_import_id.subp.subp",
161+
side_effect=[
162+
ProcessExecutionError(exit_code=1, stderr="try1"),
163+
None,
164+
],
165+
)
166+
m_which.return_value = "/usr/bin/ssh-import-id"
167+
cc_ssh_import_id.import_ssh_ids(["waffle"], "bob")
168+
169+
assert m_subp.call_count == 2
170+
assert m_sleep.call_count == 1

0 commit comments

Comments
 (0)