Skip to content

Commit 959d7f0

Browse files
authored
Merge pull request #1020 from yanksyoon/fix/machine_ssh_cherry_picked
#1020 #### Description This is a follow-up PR to #1016 , which contains cherry-picked commits without type imports. Fİxes #997 #### QA Steps *<Commands / tests / steps to run to verify that the change works:>* ``` tox -e py3 -- tests/unit/... ``` ``` tox -e integration -- tests/integration/test_machine.py ``` All CI tests need to pass. *<Please note that most likely an additional test will be required by the reviewers for any change that's not a one liner to land.>* #### Notes & Discussion *<Additional notes for the reviewers if needed. Please delete section if not applicable.>*
2 parents fbfa21b + 1f9b6b3 commit 959d7f0

2 files changed

Lines changed: 65 additions & 17 deletions

File tree

juju/machine.py

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33

44
import ipaddress
55
import logging
6+
import typing
67

78
import pyrfc3339
89

9-
from . import model, tag, jasyncio
10+
from . import jasyncio, model, tag
1011
from .annotationhelper import _get_annotations, _set_annotations
1112
from .client import client
1213
from .errors import JujuError
13-
from juju.utils import juju_ssh_key_paths
14+
from juju.utils import juju_ssh_key_paths, block_until
1415

1516
log = logging.getLogger(__name__)
1617

@@ -70,7 +71,7 @@ def _format_addr(self, addr):
7071
return fmt.format(ipaddr)
7172

7273
async def scp_to(self, source, destination, user='ubuntu', proxy=False,
73-
scp_opts=''):
74+
scp_opts='', wait_for_active=False, timeout=None):
7475
"""Transfer files to this machine.
7576
7677
:param str source: Local path of file(s) to transfer
@@ -79,10 +80,13 @@ async def scp_to(self, source, destination, user='ubuntu', proxy=False,
7980
:param bool proxy: Proxy through the Juju API server
8081
:param scp_opts: Additional options to the `scp` command
8182
:type scp_opts: str or list
83+
:param bool wait_for_active: Wait until the machine is ready to take in ssh commands.
84+
:param int timeout: Time in seconds to wait until the machine becomes ready.
8285
"""
8386
if proxy:
8487
raise NotImplementedError('proxy option is not implemented')
85-
88+
if wait_for_active:
89+
await block_until(lambda: self.addresses, timeout=timeout)
8690
try:
8791
# if dns_name is an IP address format it appropriately
8892
address = self._format_addr(self.dns_name)
@@ -93,7 +97,7 @@ async def scp_to(self, source, destination, user='ubuntu', proxy=False,
9397
await self._scp(source, destination, scp_opts)
9498

9599
async def scp_from(self, source, destination, user='ubuntu', proxy=False,
96-
scp_opts=''):
100+
scp_opts='', wait_for_active=False, timeout=None):
97101
"""Transfer files from this machine.
98102
99103
:param str source: Remote path of file(s) to transfer
@@ -102,10 +106,13 @@ async def scp_from(self, source, destination, user='ubuntu', proxy=False,
102106
:param bool proxy: Proxy through the Juju API server
103107
:param scp_opts: Additional options to the `scp` command
104108
:type scp_opts: str or list
109+
:param bool wait_for_active: Wait until the machine is ready to take in ssh commands.
110+
:param int timeout: Time in seconds to wait until the machine becomes ready.
105111
"""
106112
if proxy:
107113
raise NotImplementedError('proxy option is not implemented')
108-
114+
if wait_for_active:
115+
await block_until(lambda: self.addresses, timeout=timeout)
109116
try:
110117
# if dns_name is an IP address format it appropriately
111118
address = self._format_addr(self.dns_name)
@@ -129,23 +136,37 @@ async def _scp(self, source, destination, scp_opts):
129136
]
130137
cmd.extend(scp_opts.split() if isinstance(scp_opts, str) else scp_opts)
131138
cmd.extend([source, destination])
132-
process = await jasyncio.create_subprocess_exec(*cmd)
133-
await process.wait()
139+
# There's a bit of a gap between the time that the machine is assigned an IP and the ssh
140+
# service is up and listening, which creates a race for the ssh command. So we retry a
141+
# couple of times until either we run out of attempts, or the ssh command succeeds to
142+
# mitigate that effect.
143+
# TODO (cderici): refactor the ssh and scp subcommand processing into a single method.
144+
retry_backoff = 2
145+
retries = 10
146+
for _ in range(retries):
147+
process = await jasyncio.create_subprocess_exec(*cmd)
148+
await process.wait()
149+
if process.returncode == 0:
150+
break
151+
await jasyncio.sleep(retry_backoff)
134152
if process.returncode != 0:
135-
raise JujuError("command failed: %s" % cmd)
153+
raise JujuError(f"command failed after {retries} attempts: {cmd}")
136154

137155
async def ssh(
138-
self, command, user='ubuntu', proxy=False, ssh_opts=None):
156+
self, command, user='ubuntu', proxy=False, ssh_opts=None, wait_for_active=False, timeout=None):
139157
"""Execute a command over SSH on this machine.
140158
141159
:param str command: Command to execute
142160
:param str user: Remote username
143161
:param bool proxy: Proxy through the Juju API server
144162
:param str ssh_opts: Additional options to the `ssh` command
145-
163+
:param bool wait_for_active: Wait until the machine is ready to take in ssh commands.
164+
:param int timeout: Time in seconds to wait until the machine becomes ready.
146165
"""
147166
if proxy:
148167
raise NotImplementedError('proxy option is not implemented')
168+
if wait_for_active:
169+
await block_until(lambda: self.addresses, timeout=timeout)
149170
address = self.dns_name
150171
destination = "{}@{}".format(user, address)
151172
_, id_path = juju_ssh_key_paths()
@@ -159,14 +180,32 @@ async def ssh(
159180
if ssh_opts:
160181
cmd.extend(ssh_opts.split() if isinstance(ssh_opts, str) else ssh_opts)
161182
cmd.extend([command])
162-
process = await jasyncio.create_subprocess_exec(
163-
*cmd, stdout=jasyncio.subprocess.PIPE, stderr=jasyncio.subprocess.PIPE)
164-
stdout, stderr = await process.communicate()
183+
184+
# There's a bit of a gap between the time that the machine is assigned an IP and the ssh
185+
# service is up and listening, which creates a race for the ssh command. So we retry a
186+
# couple of times until either we run out of attempts, or the ssh command succeeds to
187+
# mitigate that effect.
188+
retry_backoff = 2
189+
retries = 10
190+
for _ in range(retries):
191+
process = await jasyncio.create_subprocess_exec(
192+
*cmd, stdout=jasyncio.subprocess.PIPE, stderr=jasyncio.subprocess.PIPE)
193+
stdout, stderr = await process.communicate()
194+
if process.returncode == 0:
195+
break
196+
await jasyncio.sleep(retry_backoff)
165197
if process.returncode != 0:
166-
raise JujuError("command failed: %s with %s" % (cmd, stderr.decode()))
198+
raise JujuError(f"command failed: {cmd} after {retries} attempts, with {stderr.decode()}")
167199
# stdout is a bytes-like object, returning a string might be more useful
168200
return stdout.decode()
169201

202+
@property
203+
def addresses(self) -> typing.List[str]:
204+
"""Returns the machine addresses.
205+
206+
"""
207+
return self.safe_data['addresses'] or []
208+
170209
@property
171210
def agent_status(self):
172211
"""Returns the current Juju agent status string.
@@ -221,11 +260,10 @@ def dns_name(self):
221260
222261
May return None if no suitable address is found.
223262
"""
224-
addresses = self.safe_data['addresses'] or []
225263
ordered_addresses = []
226264
ordered_scopes = ['public', 'local-cloud', 'local-fan']
227265
for scope in ordered_scopes:
228-
for address in addresses:
266+
for address in self.addresses:
229267
if scope == address['scope']:
230268
ordered_addresses.append(address)
231269
for address in ordered_addresses:

tests/integration/test_machine.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from .. import base
9+
from juju.machine import Machine
910

1011

1112
@base.bootstrapped
@@ -36,3 +37,12 @@ async def test_status():
3637
machine.status_message.lower() == 'running' and
3738
machine.agent_status == 'started')),
3839
timeout=480)
40+
41+
42+
@base.bootstrapped
43+
async def test_machine_ssh():
44+
async with base.CleanModel() as model:
45+
machine: Machine = await model.add_machine()
46+
out = await machine.ssh("echo hello world!", wait_for_active=True)
47+
48+
assert out == "hello world!\n"

0 commit comments

Comments
 (0)