blob: 00c1019274ea7becb8ba49f8c9592489f2c686b8 [file] [log] [blame]
Adam Israel83f8ee22019-09-03 18:26:08 -04001# Copyright 2019 Canonical Ltd.
2#
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
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
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.
14import logging
15import os
16import re
17import shlex
18import tempfile
19import time
20import uuid
21from subprocess import CalledProcessError
22
23import paramiko
24import n2vc.exceptions
25
26from juju.client import client
27
28arches = [
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"],
35
36]
37
38
39def normalize_arch(rawArch):
40 """Normalize the architecture string."""
41 for arch in arches:
42 if arch[0].match(rawArch):
43 return arch[1]
44
45
46DETECTION_SCRIPT = """#!/bin/bash
47set -e
48os_id=$(grep '^ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
49if [ "$os_id" = 'centos' ]; then
50 os_version=$(grep '^VERSION_ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
51 echo "centos$os_version"
52else
53 lsb_release -cs
54fi
55uname -m
56grep MemTotal /proc/meminfo
57cat /proc/cpuinfo
58"""
59
60INITIALIZE_UBUNTU_SCRIPT = """set -e
61(id ubuntu &> /dev/null) || useradd -m ubuntu -s /bin/bash
62umask 0077
63temp=$(mktemp)
64echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > $temp
65install -m 0440 $temp /etc/sudoers.d/90-juju-ubuntu
66rm $temp
67su ubuntu -c 'install -D -m 0600 /dev/null ~/.ssh/authorized_keys'
68export authorized_keys="{}"
69if [ ! -z "$authorized_keys" ]; then
70 su ubuntu -c 'echo $authorized_keys >> ~/.ssh/authorized_keys'
71fi
72"""
73
74IPTABLES_SCRIPT = """#!/bin/bash
75set -e
Adam Israel13a99962019-11-12 09:37:57 -080076DEBIAN_FRONTEND=noninteractive apt-get update
Adam Israel83f8ee22019-09-03 18:26:08 -040077DEBIAN_FRONTEND=noninteractive apt-get install -yqq iptables-persistent
78iptables -t nat -A OUTPUT -p tcp -d {} -j DNAT --to-destination {}
79netfilter-persistent save
80"""
81
82class SSHProvisioner:
83 """Provision a manually created machine via SSH."""
84
85 def __init__(self, user, host, private_key_path, log=None):
86
87 self.host = host
88 self.user = user
89 self.private_key_path = private_key_path
90
91 if log:
92 self.log = log
93 else:
94 self.log = logging.getLogger(__name__)
95
96 def _get_ssh_client(self, host=None, user=None, private_key_path=None):
97 """Return a connected Paramiko ssh object.
98
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.
102
103 :return: object: A paramiko.SSHClient
104 :raises: :class:`paramiko.ssh_exception.SSHException` if the
105 connection failed
106 """
107
108 if not host:
109 host = self.host
110
111 if not user:
112 user = self.user
113
114 if not private_key_path:
115 private_key_path = self.private_key_path
116
117 ssh = paramiko.SSHClient()
118 ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
119
120 pkey = None
121
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)
126
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 #######################################################################
139
140 retry = 10
141 attempts = 0
142 delay = 15
143 while attempts <= retry:
144 try:
145 attempts += 1
146
147 # Attempt to establish a SSH connection
148 ssh.connect(
149 host,
150 port=22,
151 username=user,
152 pkey=pkey,
153 # allow_agent=False,
154 # look_for_keys=False,
155 )
156 break
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)
161 else:
162 # Reraise the original exception
163 self.log.debug("Unhandled exception caught: {}".format(e))
164 raise 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))
168 if attempts > retry:
169 raise e
170 else:
171 time.sleep(delay)
172 # Slowly back off the retry
173 delay += 15
174 else:
175 self.log.debug(e)
176 raise e
177 return ssh
178
179 def _run_command(self, ssh, cmd, pty=True):
180 """Run a command remotely via SSH.
181
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
186
187 :return: tuple: The stdout and stderr of the command execution
188 :raises: :class:`CalledProcessError` if the command fails
189 """
190
191 if isinstance(cmd, str):
192 cmd = shlex.split(cmd)
193
194 if type(cmd) is not list:
195 cmd = [cmd]
196
197 cmds = ' '.join(cmd)
198 stdin, stdout, stderr = ssh.exec_command(cmds, get_pty=pty)
199 retcode = stdout.channel.recv_exit_status()
200
201 if retcode > 0:
202 output = stderr.read().strip()
203 raise CalledProcessError(returncode=retcode, cmd=cmd,
204 output=output)
205 return (
206 stdout.read().decode('utf-8').strip(),
207 stderr.read().decode('utf-8').strip()
208 )
209
210 def _init_ubuntu_user(self):
211 """Initialize the ubuntu user.
212
213 :return: bool: If the initialization was successful
214 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
215 if the authentication fails
216 """
217 ssh = None
218 try:
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)
226 finally:
227 if ssh:
228 ssh.close()
229
230 # Infer the public key
231 public_key = None
232 public_key_path = "{}.pub".format(self.private_key_path)
233
234 if not os.path.exists(public_key_path):
235 raise FileNotFoundError(
236 "Public key '{}' doesn't exist.".format(public_key_path)
237 )
238
239 with open(public_key_path, "r") as f:
240 public_key = f.readline()
241
242 script = INITIALIZE_UBUNTU_SCRIPT.format(public_key)
243
244 try:
245 ssh = self._get_ssh_client()
246
247 self._run_command(
248 ssh,
249 ["sudo", "/bin/bash -c " + shlex.quote(script)],
250 pty=True
251 )
252 except paramiko.ssh_exception.AuthenticationException as e:
253 raise e
254 finally:
255 ssh.close()
256
257 return True
258
259 def _detect_hardware_and_os(self, ssh):
260 """Detect the target hardware capabilities and OS series.
261
262 :param object ssh: The SSHClient
263 :return: str: A raw string containing OS and hardware information.
264 """
265
266 info = {
267 'series': '',
268 'arch': '',
269 'cpu-cores': '',
270 'mem': '',
271 }
272
273 stdout, stderr = self._run_command(
274 ssh,
275 ["sudo", "/bin/bash -c " + shlex.quote(DETECTION_SCRIPT)],
276 pty=True,
277 )
278
279 lines = stdout.split("\n")
280
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]:
284 lines = lines[1:]
285
286 info['series'] = lines[0].strip()
287 info['arch'] = normalize_arch(lines[1].strip())
288
289 memKb = re.split(r'\s+', lines[2])[1]
290
291 # Convert megabytes -> kilobytes
292 info['mem'] = round(int(memKb) / 1024)
293
294 # Detect available CPUs
295 recorded = {}
296 for line in lines[3:]:
297 physical_id = ""
298
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()
303
304 if physical_id not in recorded.keys():
305 info['cpu-cores'] += cores
306 recorded[physical_id] = True
307
308 return info
309
310 def provision_machine(self):
311 """Perform the initial provisioning of the target machine.
312
313 :return: bool: The client.AddMachineParams
314 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
315 if the upload fails
316 """
317 params = client.AddMachineParams()
318
319 if self._init_ubuntu_user():
320 try:
Adam Israel13a99962019-11-12 09:37:57 -0800321 step = "get ssh client"
Adam Israel83f8ee22019-09-03 18:26:08 -0400322 ssh = self._get_ssh_client()
323
Adam Israel13a99962019-11-12 09:37:57 -0800324 step = "detect hardware and os"
Adam Israel83f8ee22019-09-03 18:26:08 -0400325 hw = self._detect_hardware_and_os(ssh)
Adam Israel13a99962019-11-12 09:37:57 -0800326
327 step = "series"
Adam Israel83f8ee22019-09-03 18:26:08 -0400328 params.series = hw['series']
Adam Israel13a99962019-11-12 09:37:57 -0800329
330 step = "instance_id"
Adam Israel83f8ee22019-09-03 18:26:08 -0400331 params.instance_id = "manual:{}".format(self.host)
Adam Israel13a99962019-11-12 09:37:57 -0800332
333 step = "nonce"
Adam Israel83f8ee22019-09-03 18:26:08 -0400334 params.nonce = "manual:{}:{}".format(
335 self.host,
336 str(uuid.uuid4()), # a nop for Juju w/manual machines
337 )
Adam Israel13a99962019-11-12 09:37:57 -0800338 step = "hw characteristics"
Adam Israel83f8ee22019-09-03 18:26:08 -0400339 params.hardware_characteristics = {
340 'arch': hw['arch'],
341 'mem': int(hw['mem']),
342 'cpu-cores': int(hw['cpu-cores']),
343 }
Adam Israel13a99962019-11-12 09:37:57 -0800344 step = "addresses"
Adam Israel83f8ee22019-09-03 18:26:08 -0400345 params.addresses = [{
346 'value': self.host,
347 'type': 'ipv4',
348 'scope': 'public',
349 }]
350
351 except paramiko.ssh_exception.AuthenticationException as e:
352 raise e
Adam Israel13a99962019-11-12 09:37:57 -0800353 except Exception as e:
354 self.log.debug("Caught exception inside provision_machine step {}: {}".format(step, e))
355 raise e
Adam Israel83f8ee22019-09-03 18:26:08 -0400356 finally:
357 ssh.close()
358
359 return params
360
361 async def install_agent(self, connection, nonce, machine_id, api):
362 """
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
366
367 :return: bool: If the initialization was successful
368 """
369 # The path where the Juju agent should be installed.
370 data_dir = "/var/lib/juju"
371
372 # Disabling this prevents `apt-get update` from running initially, so
373 # charms will fail to deploy
374 disable_package_commands = False
375
376 client_facade = client.ClientFacade.from_connection(connection)
377 results = await client_facade.ProvisioningScript(
378 data_dir=data_dir,
379 disable_package_commands=disable_package_commands,
380 machine_id=machine_id,
381 nonce=nonce,
382 )
383
384 """Get the IP of the controller
385
386 Parse the provisioning script, looking for the first apiaddress.
387
388 Example:
389 apiaddresses:
390 - 10.195.8.2:17070
391 - 127.0.0.1:17070
392 - '[::1]:17070'
393 """
394 m = re.search('apiaddresses:\n- (\d+\.\d+\.\d+\.\d+):17070', results.script)
395 apiaddress = m.group(1)
396
397 """Add IP Table rule
398
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.
404 """
405
406 script = IPTABLES_SCRIPT.format(apiaddress, api)
407
408 # Run this in a retry loop, because dpkg may be running and cause the
409 # script to fail.
410 retry = 10
411 attempts = 0
412 delay = 15
413 while attempts <= retry:
414 try:
415 attempts += 1
416
417 self._run_configure_script(script)
418 break
419 except Exception as e:
420 self.log.debug("Waiting for dpkg, sleeping {} seconds".format(delay))
421 if attempts > retry:
422 raise e
423 else:
424 time.sleep(delay)
425 # Slowly back off the retry
426 delay += 15
427
428 # self.log.debug("Running configure script")
429 self._run_configure_script(results.script)
430 # self.log.debug("Configure script finished")
431
432
433
434 def _run_configure_script(self, script: str):
435 """Run the script to install the Juju agent on the target machine.
436
437 :param str script: The script returned by the ProvisioningScript API
438 :raises: :class:`paramiko.ssh_exception.AuthenticationException`
439 if the upload fails
440 """
441 _, tmpFile = tempfile.mkstemp()
442 with open(tmpFile, 'w') as f:
443 f.write(script)
444 try:
445 # get ssh client
446 ssh = self._get_ssh_client(
447 user="ubuntu",
448 )
449
450 # copy the local copy of the script to the remote machine
451 sftp = paramiko.SFTPClient.from_transport(ssh.get_transport())
452 sftp.put(
453 tmpFile,
454 tmpFile,
455 )
456
457 # run the provisioning script
458 stdout, stderr = self._run_command(
459 ssh,
460 "sudo /bin/bash {}".format(tmpFile),
461 )
462
463 except paramiko.ssh_exception.AuthenticationException as e:
464 raise e
465 finally:
466 os.remove(tmpFile)
467 ssh.close()