1 # Copyright 2019 Canonical Ltd.
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
7 # http://www.apache.org/licenses/LICENSE-2.0
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
21 from subprocess
import CalledProcessError
24 import n2vc
.exceptions
26 from juju
.client
import client
29 [re
.compile(r
"amd64|x86_64"), "amd64"],
30 [re
.compile(r
"i?[3-9]86"), "i386"],
31 [re
.compile(r
"(arm$)|(armv.*)"), "armhf"],
32 [re
.compile(r
"aarch64"), "arm64"],
33 [re
.compile(r
"ppc64|ppc64el|ppc64le"), "ppc64el"],
34 [re
.compile(r
"s390x?"), "s390x"],
39 def normalize_arch(rawArch
):
40 """Normalize the architecture string."""
42 if arch
[0].match(rawArch
):
46 DETECTION_SCRIPT
= """#!/bin/bash
48 os_id=$(grep '^ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
49 if [ "$os_id" = 'centos' ]; then
50 os_version=$(grep '^VERSION_ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
51 echo "centos$os_version"
56 grep MemTotal /proc/meminfo
60 INITIALIZE_UBUNTU_SCRIPT
= """set -e
61 (id ubuntu &> /dev/null) || useradd -m ubuntu -s /bin/bash
64 echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > $temp
65 install -m 0440 $temp /etc/sudoers.d/90-juju-ubuntu
67 su ubuntu -c 'install -D -m 0600 /dev/null ~/.ssh/authorized_keys'
68 export authorized_keys="{}"
69 if [ ! -z "$authorized_keys" ]; then
70 su ubuntu -c 'echo $authorized_keys >> ~/.ssh/authorized_keys'
74 IPTABLES_SCRIPT
= """#!/bin/bash
76 DEBIAN_FRONTEND=noninteractive apt-get update
77 DEBIAN_FRONTEND=noninteractive apt-get install -yqq iptables-persistent
78 iptables -t nat -A OUTPUT -p tcp -d {} -j DNAT --to-destination {}
79 netfilter-persistent save
83 """Provision a manually created machine via SSH."""
85 def __init__(self
, user
, host
, private_key_path
, log
=None):
89 self
.private_key_path
= private_key_path
94 self
.log
= logging
.getLogger(__name__
)
96 def _get_ssh_client(self
, host
=None, user
=None, private_key_path
=None):
97 """Return a connected Paramiko ssh object.
99 :param str host: The host to connect to.
100 :param str user: The user to connect as.
101 :param str key: The private key to authenticate with.
103 :return: object: A paramiko.SSHClient
104 :raises: :class:`paramiko.ssh_exception.SSHException` if the
114 if not private_key_path
:
115 private_key_path
= self
.private_key_path
117 ssh
= paramiko
.SSHClient()
118 ssh
.set_missing_host_key_policy(paramiko
.AutoAddPolicy())
122 # Read the private key into a paramiko.RSAKey
123 if os
.path
.exists(private_key_path
):
124 with
open(private_key_path
, 'r') as f
:
125 pkey
= paramiko
.RSAKey
.from_private_key(f
)
127 #######################################################################
128 # There is a bug in some versions of OpenSSH 4.3 (CentOS/RHEL5) where #
129 # the server may not send the SSH_MSG_USERAUTH_BANNER message except #
130 # when responding to an auth_none request. For example, paramiko will #
131 # attempt to use password authentication when a password is set, but #
132 # the server could deny that, instead requesting keyboard-interactive.#
133 # The hack to workaround this is to attempt a reconnect, which will #
134 # receive the right banner, and authentication can proceed. See the #
135 # following for more info: #
136 # https://github.com/paramiko/paramiko/issues/432 #
137 # https://github.com/paramiko/paramiko/pull/438 #
138 #######################################################################
143 while attempts
<= retry
:
147 # Attempt to establish a SSH connection
154 # look_for_keys=False,
157 except paramiko
.ssh_exception
.SSHException
as e
:
158 if 'Error reading SSH protocol banner' == str(e
):
159 # Once more, with feeling
160 ssh
.connect(host
, port
=22, username
=user
, pkey
=pkey
)
162 # Reraise the original exception
163 self
.log
.debug("Unhandled exception caught: {}".format(e
))
165 except Exception as e
:
166 if 'Unable to connect to port' in str(e
):
167 self
.log
.debug("Waiting for VM to boot, sleeping {} seconds".format(delay
))
172 # Slowly back off the retry
179 def _run_command(self
, ssh
, cmd
, pty
=True):
180 """Run a command remotely via SSH.
182 :param object ssh: The SSHClient
183 :param str cmd: The command to execute
184 :param list cmd: The `shlex.split` command to execute
185 :param bool pty: Whether to allocate a pty
187 :return: tuple: The stdout and stderr of the command execution
188 :raises: :class:`CalledProcessError` if the command fails
191 if isinstance(cmd
, str):
192 cmd
= shlex
.split(cmd
)
194 if type(cmd
) is not list:
198 stdin
, stdout
, stderr
= ssh
.exec_command(cmds
, get_pty
=pty
)
199 retcode
= stdout
.channel
.recv_exit_status()
202 output
= stderr
.read().strip()
203 raise CalledProcessError(returncode
=retcode
, cmd
=cmd
,
206 stdout
.read().decode('utf-8').strip(),
207 stderr
.read().decode('utf-8').strip()
210 def _init_ubuntu_user(self
):
211 """Initialize the ubuntu user.
213 :return: bool: If the initialization was successful
214 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
215 if the authentication fails
219 # Run w/o allocating a pty, so we fail if sudo prompts for a passwd
220 ssh
= self
._get
_ssh
_client
()
221 stdout
, stderr
= self
._run
_command
(ssh
, "sudo -n true", pty
=False)
222 except paramiko
.ssh_exception
.AuthenticationException
:
223 raise n2vc
.exceptions
.AuthenticationFailed(self
.user
)
224 except paramiko
.ssh_exception
.NoValidConnectionsError
:
225 raise n2vc
.exceptions
.NoRouteToHost(self
.host
)
230 # Infer the public key
232 public_key_path
= "{}.pub".format(self
.private_key_path
)
234 if not os
.path
.exists(public_key_path
):
235 raise FileNotFoundError(
236 "Public key '{}' doesn't exist.".format(public_key_path
)
239 with
open(public_key_path
, "r") as f
:
240 public_key
= f
.readline()
242 script
= INITIALIZE_UBUNTU_SCRIPT
.format(public_key
)
245 ssh
= self
._get
_ssh
_client
()
249 ["sudo", "/bin/bash -c " + shlex
.quote(script
)],
252 except paramiko
.ssh_exception
.AuthenticationException
as e
:
259 def _detect_hardware_and_os(self
, ssh
):
260 """Detect the target hardware capabilities and OS series.
262 :param object ssh: The SSHClient
263 :return: str: A raw string containing OS and hardware information.
273 stdout
, stderr
= self
._run
_command
(
275 ["sudo", "/bin/bash -c " + shlex
.quote(DETECTION_SCRIPT
)],
279 lines
= stdout
.split("\n")
281 # Remove extraneous line if DNS resolution of hostname famils
282 # i.e. sudo: unable to resolve host test-1-mgmtvm-1: Connection timed out
283 if 'unable to resolve host' in lines
[0]:
286 info
['series'] = lines
[0].strip()
287 info
['arch'] = normalize_arch(lines
[1].strip())
289 memKb
= re
.split(r
'\s+', lines
[2])[1]
291 # Convert megabytes -> kilobytes
292 info
['mem'] = round(int(memKb
) / 1024)
294 # Detect available CPUs
296 for line
in lines
[3:]:
299 if line
.find("physical id") == 0:
300 physical_id
= line
.split(":")[1].strip()
301 elif line
.find("cpu cores") == 0:
302 cores
= line
.split(":")[1].strip()
304 if physical_id
not in recorded
.keys():
305 info
['cpu-cores'] += cores
306 recorded
[physical_id
] = True
310 def provision_machine(self
):
311 """Perform the initial provisioning of the target machine.
313 :return: bool: The client.AddMachineParams
314 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
317 params
= client
.AddMachineParams()
319 if self
._init
_ubuntu
_user
():
321 step
= "get ssh client"
322 ssh
= self
._get
_ssh
_client
()
324 step
= "detect hardware and os"
325 hw
= self
._detect
_hardware
_and
_os
(ssh
)
328 params
.series
= hw
['series']
331 params
.instance_id
= "manual:{}".format(self
.host
)
334 params
.nonce
= "manual:{}:{}".format(
336 str(uuid
.uuid4()), # a nop for Juju w/manual machines
338 step
= "hw characteristics"
339 params
.hardware_characteristics
= {
341 'mem': int(hw
['mem']),
342 'cpu-cores': int(hw
['cpu-cores']),
345 params
.addresses
= [{
351 except paramiko
.ssh_exception
.AuthenticationException
as e
:
353 except Exception as e
:
354 self
.log
.debug("Caught exception inside provision_machine step {}: {}".format(step
, e
))
361 async def install_agent(self
, connection
, nonce
, machine_id
, api
):
363 :param object connection: Connection to Juju API
364 :param str nonce: The nonce machine specification
365 :param str machine_id: The id assigned to the machine
367 :return: bool: If the initialization was successful
369 # The path where the Juju agent should be installed.
370 data_dir
= "/var/lib/juju"
372 # Disabling this prevents `apt-get update` from running initially, so
373 # charms will fail to deploy
374 disable_package_commands
= False
376 client_facade
= client
.ClientFacade
.from_connection(connection
)
377 results
= await client_facade
.ProvisioningScript(
379 disable_package_commands
=disable_package_commands
,
380 machine_id
=machine_id
,
384 """Get the IP of the controller
386 Parse the provisioning script, looking for the first apiaddress.
394 m
= re
.search('apiaddresses:\n- (\d+\.\d+\.\d+\.\d+):17070', results
.script
)
395 apiaddress
= m
.group(1)
399 In order to route the traffic to the private ip of the Juju controller
400 we use a DNAT rule to tell the machine that the destination for the
401 private address is the public address of the machine where the Juju
402 controller is running in LXD. That machine will have a complimentary
403 iptables rule, routing traffic to the appropriate LXD container.
406 script
= IPTABLES_SCRIPT
.format(apiaddress
, api
)
408 # Run this in a retry loop, because dpkg may be running and cause the
413 while attempts
<= retry
:
417 self
._run
_configure
_script
(script
)
419 except Exception as e
:
420 self
.log
.debug("Waiting for dpkg, sleeping {} seconds".format(delay
))
425 # Slowly back off the retry
428 # self.log.debug("Running configure script")
429 self
._run
_configure
_script
(results
.script
)
430 # self.log.debug("Configure script finished")
434 def _run_configure_script(self
, script
: str):
435 """Run the script to install the Juju agent on the target machine.
437 :param str script: The script returned by the ProvisioningScript API
438 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
441 _
, tmpFile
= tempfile
.mkstemp()
442 with
open(tmpFile
, 'w') as f
:
446 ssh
= self
._get
_ssh
_client
(
450 # copy the local copy of the script to the remote machine
451 sftp
= paramiko
.SFTPClient
.from_transport(ssh
.get_transport())
457 # run the provisioning script
458 stdout
, stderr
= self
._run
_command
(
460 "sudo /bin/bash {}".format(tmpFile
),
463 except paramiko
.ssh_exception
.AuthenticationException
as e
: