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.
19 from subprocess
import CalledProcessError
23 from .client
import client
26 [re
.compile(r
"amd64|x86_64"), "amd64"],
27 [re
.compile(r
"i?[3-9]86"), "i386"],
28 [re
.compile(r
"(arm$)|(armv.*)"), "armhf"],
29 [re
.compile(r
"aarch64"), "arm64"],
30 [re
.compile(r
"ppc64|ppc64el|ppc64le"), "ppc64el"],
31 [re
.compile(r
"s390x?"), "s390x"],
36 def normalize_arch(rawArch
):
37 """Normalize the architecture string."""
39 if arch
[0].match(rawArch
):
43 DETECTION_SCRIPT
= """#!/bin/bash
45 os_id=$(grep '^ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
46 if [ "$os_id" = 'centos' ]; then
47 os_version=$(grep '^VERSION_ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
48 echo "centos$os_version"
53 grep MemTotal /proc/meminfo
57 INITIALIZE_UBUNTU_SCRIPT
= """set -e
58 (id ubuntu &> /dev/null) || useradd -m ubuntu -s /bin/bash
61 echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > $temp
62 install -m 0440 $temp /etc/sudoers.d/90-juju-ubuntu
64 su ubuntu -c 'install -D -m 0600 /dev/null ~/.ssh/authorized_keys'
65 export authorized_keys="{}"
66 if [ ! -z "$authorized_keys" ]; then
67 su ubuntu -c 'echo $authorized_keys >> ~/.ssh/authorized_keys'
73 """Provision a manually created machine via SSH."""
78 def __init__(self
, user
, host
, private_key_path
):
81 self
.private_key_path
= private_key_path
83 def _get_ssh_client(self
, host
, user
, key
):
84 """Return a connected Paramiko ssh object.
86 :param str host: The host to connect to.
87 :param str user: The user to connect as.
88 :param str key: The private key to authenticate with.
90 :return: object: A paramiko.SSHClient
91 :raises: :class:`paramiko.ssh_exception.SSHException` if the
95 ssh
= paramiko
.SSHClient()
96 ssh
.set_missing_host_key_policy(paramiko
.AutoAddPolicy())
100 # Read the private key into a paramiko.RSAKey
101 if os
.path
.exists(key
):
102 with
open(key
, 'r') as f
:
103 pkey
= paramiko
.RSAKey
.from_private_key(f
)
105 #######################################################################
106 # There is a bug in some versions of OpenSSH 4.3 (CentOS/RHEL5) where #
107 # the server may not send the SSH_MSG_USERAUTH_BANNER message except #
108 # when responding to an auth_none request. For example, paramiko will #
109 # attempt to use password authentication when a password is set, but #
110 # the server could deny that, instead requesting keyboard-interactive.#
111 # The hack to workaround this is to attempt a reconnect, which will #
112 # receive the right banner, and authentication can proceed. See the #
113 # following for more info: #
114 # https://github.com/paramiko/paramiko/issues/432 #
115 # https://github.com/paramiko/paramiko/pull/438 #
116 #######################################################################
119 ssh
.connect(host
, port
=22, username
=user
, pkey
=pkey
)
120 except paramiko
.ssh_exception
.SSHException
as e
:
121 if 'Error reading SSH protocol banner' == str(e
):
122 # Once more, with feeling
123 ssh
.connect(host
, port
=22, username
=user
, pkey
=pkey
)
125 # Reraise the original exception
130 def _run_command(self
, ssh
, cmd
, pty
=True):
131 """Run a command remotely via SSH.
133 :param object ssh: The SSHClient
134 :param str cmd: The command to execute
135 :param list cmd: The `shlex.split` command to execute
136 :param bool pty: Whether to allocate a pty
138 :return: tuple: The stdout and stderr of the command execution
139 :raises: :class:`CalledProcessError` if the command fails
142 if isinstance(cmd
, str):
143 cmd
= shlex
.split(cmd
)
145 if type(cmd
) is not list:
149 stdin
, stdout
, stderr
= ssh
.exec_command(cmds
, get_pty
=pty
)
150 retcode
= stdout
.channel
.recv_exit_status()
153 output
= stderr
.read().strip()
154 raise CalledProcessError(returncode
=retcode
, cmd
=cmd
,
157 stdout
.read().decode('utf-8').strip(),
158 stderr
.read().decode('utf-8').strip()
161 def _init_ubuntu_user(self
):
162 """Initialize the ubuntu user.
164 :return: bool: If the initialization was successful
165 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
166 if the authentication fails
171 # Run w/o allocating a pty, so we fail if sudo prompts for a passwd
172 ssh
= self
._get
_ssh
_client
(
175 self
.private_key_path
,
177 stdout
, stderr
= self
._run
_command
(ssh
, "sudo -n true", pty
=False)
178 except paramiko
.ssh_exception
.AuthenticationException
as e
:
184 # Infer the public key
186 public_key_path
= "{}.pub".format(self
.private_key_path
)
188 if not os
.path
.exists(public_key_path
):
189 raise FileNotFoundError(
190 "Public key '{}' doesn't exist.".format(public_key_path
)
193 with
open(public_key_path
, "r") as f
:
194 public_key
= f
.readline()
196 script
= INITIALIZE_UBUNTU_SCRIPT
.format(public_key
)
199 ssh
= self
._get
_ssh
_client
(
202 self
.private_key_path
,
207 ["sudo", "/bin/bash -c " + shlex
.quote(script
)],
210 except paramiko
.ssh_exception
.AuthenticationException
as e
:
217 def _detect_hardware_and_os(self
, ssh
):
218 """Detect the target hardware capabilities and OS series.
220 :param object ssh: The SSHClient
221 :return: str: A raw string containing OS and hardware information.
231 stdout
, stderr
= self
._run
_command
(
233 ["sudo", "/bin/bash -c " + shlex
.quote(DETECTION_SCRIPT
)],
237 lines
= stdout
.split("\n")
238 info
['series'] = lines
[0].strip()
239 info
['arch'] = normalize_arch(lines
[1].strip())
241 memKb
= re
.split(r
'\s+', lines
[2])[1]
243 # Convert megabytes -> kilobytes
244 info
['mem'] = round(int(memKb
) / 1024)
246 # Detect available CPUs
248 for line
in lines
[3:]:
252 if line
.find("physical id") == 0:
253 physical_id
= line
.split(":")[1].strip()
254 elif line
.find("cpu cores") == 0:
255 cores
= line
.split(":")[1].strip()
257 if physical_id
not in recorded
.keys():
258 info
['cpu-cores'] += cores
259 recorded
[physical_id
] = True
263 def provision_machine(self
):
264 """Perform the initial provisioning of the target machine.
266 :return: bool: The client.AddMachineParams
267 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
270 params
= client
.AddMachineParams()
272 if self
._init
_ubuntu
_user
():
275 ssh
= self
._get
_ssh
_client
(
278 self
.private_key_path
281 hw
= self
._detect
_hardware
_and
_os
(ssh
)
282 params
.series
= hw
['series']
283 params
.instance_id
= "manual:{}".format(self
.host
)
284 params
.nonce
= "manual:{}:{}".format(
286 str(uuid
.uuid4()), # a nop for Juju w/manual machines
288 params
.hardware_characteristics
= {
290 'mem': int(hw
['mem']),
291 'cpu-cores': int(hw
['cpu-cores']),
293 params
.addresses
= [{
299 except paramiko
.ssh_exception
.AuthenticationException
as e
:
306 async def install_agent(self
, connection
, nonce
, machine_id
):
308 :param object connection: Connection to Juju API
309 :param str nonce: The nonce machine specification
310 :param str machine_id: The id assigned to the machine
312 :return: bool: If the initialization was successful
315 # The path where the Juju agent should be installed.
316 data_dir
= "/var/lib/juju"
318 # Disabling this prevents `apt-get update` from running initially, so
319 # charms will fail to deploy
320 disable_package_commands
= False
322 client_facade
= client
.ClientFacade
.from_connection(connection
)
323 results
= await client_facade
.ProvisioningScript(
325 disable_package_commands
=disable_package_commands
,
326 machine_id
=machine_id
,
330 self
._run
_configure
_script
(results
.script
)
332 def _run_configure_script(self
, script
):
333 """Run the script to install the Juju agent on the target machine.
335 :param str script: The script returned by the ProvisioningScript API
336 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
340 _
, tmpFile
= tempfile
.mkstemp()
341 with
open(tmpFile
, 'w') as f
:
346 ssh
= self
._get
_ssh
_client
(
349 self
.private_key_path
,
352 # copy the local copy of the script to the remote machine
353 sftp
= paramiko
.SFTPClient
.from_transport(ssh
.get_transport())
359 # run the provisioning script
360 stdout
, stderr
= self
._run
_command
(
362 "sudo /bin/bash {}".format(tmpFile
),
365 except paramiko
.ssh_exception
.AuthenticationException
as e
: