33
44import ipaddress
55import logging
6+ import typing
67
78import pyrfc3339
89
9- from . import model , tag , jasyncio
10+ from . import jasyncio , model , tag
1011from .annotationhelper import _get_annotations , _set_annotations
1112from .client import client
1213from .errors import JujuError
13- from juju .utils import juju_ssh_key_paths
14+ from juju .utils import juju_ssh_key_paths , block_until
1415
1516log = 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 :
0 commit comments