Code Coverage

Cobertura Coverage Report > n2vc >

provisioner.py

Trend

File Coverage summary

NameClassesLinesConditionals
provisioner.py
100%
1/1
20%
27/136
100%
0/0

Coverage Breakdown by Class

NameLinesConditionals
provisioner.py
20%
27/136
N/A

Source

n2vc/provisioner.py
1 # 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.
14 1 import logging
15 1 import os
16 1 import re
17 1 from subprocess import CalledProcessError
18 1 import tempfile
19 1 import uuid
20
21 1 from juju.client import client
22 1 import asyncio
23
24 1 arches = [
25     [re.compile(r"amd64|x86_64"), "amd64"],
26     [re.compile(r"i?[3-9]86"), "i386"],
27     [re.compile(r"(arm$)|(armv.*)"), "armhf"],
28     [re.compile(r"aarch64"), "arm64"],
29     [re.compile(r"ppc64|ppc64el|ppc64le"), "ppc64el"],
30     [re.compile(r"s390x?"), "s390x"],
31 ]
32
33
34 1 def normalize_arch(rawArch):
35     """Normalize the architecture string."""
36 0     for arch in arches:
37 0         if arch[0].match(rawArch):
38 0             return arch[1]
39
40
41 1 DETECTION_SCRIPT = """#!/bin/bash
42 set -e
43 os_id=$(grep '^ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
44 if [ "$os_id" = 'centos' ] || [ "$os_id" = 'rhel' ] ; then
45   os_version=$(grep '^VERSION_ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
46   echo "$os_id$os_version"
47 else
48   lsb_release -cs
49 fi
50 uname -m
51 grep MemTotal /proc/meminfo
52 cat /proc/cpuinfo
53 """
54
55 1 INITIALIZE_UBUNTU_SCRIPT = """set -e
56 (id ubuntu &> /dev/null) || useradd -m ubuntu -s /bin/bash
57 umask 0077
58 temp=$(mktemp)
59 echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > $temp
60 install -m 0440 $temp /etc/sudoers.d/90-juju-ubuntu
61 rm $temp
62 su ubuntu -c '[ -f ~/.ssh/authorized_keys ] || install -D -m 0600 /dev/null ~/.ssh/authorized_keys'
63 export authorized_keys="{}"
64 if [ ! -z "$authorized_keys" ]; then
65     su ubuntu -c 'echo $authorized_keys >> ~/.ssh/authorized_keys'
66 fi
67 """
68
69 1 IPTABLES_SCRIPT = """#!/bin/bash
70 set -e
71 [ -v `which netfilter-persistent` ] && apt update \
72     && DEBIAN_FRONTEND=noninteractive apt-get install -yqq iptables-persistent
73 iptables -t nat -A OUTPUT -p tcp -d {} -j DNAT --to-destination {}
74 netfilter-persistent save
75 """
76
77 1 IPTABLES_SCRIPT_RHEL = """#!/bin/bash
78 set -e
79 [ -v `which firewalld` ] && yum install -q -y firewalld
80 systemctl is-active --quiet firewalld || systemctl start firewalld \
81     && firewall-cmd --permanent --zone=public --set-target=ACCEPT
82 systemctl is-enabled --quiet firewalld || systemctl enable firewalld
83 firewall-cmd --direct --permanent --add-rule ipv4 nat OUTPUT 0 -d {} -p tcp \
84     -j DNAT --to-destination {}
85 firewall-cmd --reload
86 """
87
88 1 CLOUD_INIT_WAIT_SCRIPT = """#!/bin/bash
89 set -e
90 cloud-init status --wait
91 """
92
93
94 1 class AsyncSSHProvisioner:
95     """Provision a manually created machine via SSH."""
96
97 1     user = ""
98 1     host = ""
99 1     private_key_path = ""
100
101 1     def __init__(self, user, host, private_key_path, log=None):
102 0         self.host = host
103 0         self.user = user
104 0         self.private_key_path = private_key_path
105 0         self.log = log if log else logging.getLogger(__name__)
106
107 1     async def _scp(self, source_file, destination_file):
108         """Execute an scp command. Requires a fully qualified source and
109         destination.
110
111         :param str source_file: Path to the source file
112         :param str destination_file: Path to the destination file
113         """
114 0         cmd = [
115             "scp",
116             "-i",
117             os.path.expanduser(self.private_key_path),
118             "-o",
119             "StrictHostKeyChecking=no",
120             "-q",
121             "-B",
122         ]
123 0         destination = "{}@{}:{}".format(self.user, self.host, destination_file)
124 0         cmd.extend([source_file, destination])
125 0         process = await asyncio.create_subprocess_exec(*cmd)
126 0         await process.wait()
127 0         if process.returncode != 0:
128 0             raise CalledProcessError(returncode=process.returncode, cmd=cmd)
129
130 1     async def _ssh(self, command):
131         """Run a command remotely via SSH.
132
133         :param str command: The command to execute
134         :return: tuple: The stdout and stderr of the command execution
135         :raises: :class:`CalledProcessError` if the command fails
136         """
137
138 0         destination = "{}@{}".format(self.user, self.host)
139 0         cmd = [
140             "ssh",
141             "-i",
142             os.path.expanduser(self.private_key_path),
143             "-o",
144             "StrictHostKeyChecking=no",
145             "-q",
146             destination,
147         ]
148 0         cmd.extend([command])
149 0         process = await asyncio.create_subprocess_exec(
150             *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
151         )
152 0         stdout, stderr = await process.communicate()
153
154 0         if process.returncode != 0:
155 0             output = stderr.decode("utf-8").strip()
156 0             raise CalledProcessError(
157                 returncode=process.returncode, cmd=cmd, output=output
158             )
159 0         return (stdout.decode("utf-8").strip(), stderr.decode("utf-8").strip())
160
161 1     async def _init_ubuntu_user(self):
162         """Initialize the ubuntu user.
163
164         :return: bool: If the initialization was successful
165         :raises: :class:`CalledProcessError` if the _ssh command fails
166         """
167 0         retry = 10
168 0         attempts = 0
169 0         delay = 15
170 0         while attempts <= retry:
171 0             try:
172 0                 attempts += 1
173                 # Attempt to establish a SSH connection
174 0                 stdout, stderr = await self._ssh("sudo -n true")
175 0                 break
176 0             except CalledProcessError as e:
177 0                 self.log.debug(
178                     "Waiting for VM to boot, sleeping {} seconds".format(delay)
179                 )
180 0                 if attempts > retry:
181 0                     raise e
182                 else:
183 0                     await asyncio.sleep(delay)
184                     # Slowly back off the retry
185 0                     delay += 15
186
187         # Infer the public key
188 0         public_key = None
189 0         public_key_path = "{}.pub".format(self.private_key_path)
190
191 0         if not os.path.exists(public_key_path):
192 0             raise FileNotFoundError(
193                 "Public key '{}' doesn't exist.".format(public_key_path)
194             )
195
196 0         with open(public_key_path, "r") as f:
197 0             public_key = f.readline()
198
199 0         script = INITIALIZE_UBUNTU_SCRIPT.format(public_key)
200
201 0         stdout, stderr = await self._run_configure_script(script)
202
203 0         return True
204
205 1     async def _detect_hardware_and_os(self):
206         """Detect the target hardware capabilities and OS series.
207
208         :return: str: A raw string containing OS and hardware information.
209         """
210
211 0         info = {
212             "series": "",
213             "arch": "",
214             "cpu-cores": "",
215             "mem": "",
216         }
217
218 0         stdout, stderr = await self._run_configure_script(DETECTION_SCRIPT)
219
220 0         lines = stdout.split("\n")
221 0         info["series"] = lines[0].strip()
222 0         info["arch"] = normalize_arch(lines[1].strip())
223
224 0         memKb = re.split(r"\s+", lines[2])[1]
225
226         # Convert megabytes -> kilobytes
227 0         info["mem"] = round(int(memKb) / 1024)
228
229         # Detect available CPUs
230 0         recorded = {}
231 0         for line in lines[3:]:
232 0             physical_id = ""
233 0             print(line)
234
235 0             if line.find("physical id") == 0:
236 0                 physical_id = line.split(":")[1].strip()
237 0             elif line.find("cpu cores") == 0:
238 0                 cores = line.split(":")[1].strip()
239
240 0                 if physical_id not in recorded.keys():
241 0                     info["cpu-cores"] += cores
242 0                     recorded[physical_id] = True
243
244 0         return info
245
246 1     async def provision_machine(self):
247         """Perform the initial provisioning of the target machine.
248
249         :return: bool: The client.AddMachineParams
250         """
251 0         params = client.AddMachineParams()
252
253 0         if await self._init_ubuntu_user():
254 0             hw = await self._detect_hardware_and_os()
255 0             params.series = hw["series"]
256 0             params.instance_id = "manual:{}".format(self.host)
257 0             params.nonce = "manual:{}:{}".format(
258                 self.host,
259                 str(uuid.uuid4()),
260             )  # a nop for Juju w/manual machines
261 0             params.hardware_characteristics = {
262                 "arch": hw["arch"],
263                 "mem": int(hw["mem"]),
264                 "cpu-cores": int(hw["cpu-cores"]),
265             }
266 0             params.addresses = [{"value": self.host, "type": "ipv4", "scope": "public"}]
267
268 0         return params
269
270 1     async def install_agent(
271         self, connection, nonce, machine_id, proxy=None, series=None
272     ):
273         """
274         :param object connection: Connection to Juju API
275         :param str nonce: The nonce machine specification
276         :param str machine_id: The id assigned to the machine
277         :param str proxy: IP of the API_PROXY
278         :param str series: OS name
279
280         :return: bool: If the initialization was successful
281         """
282         # The path where the Juju agent should be installed.
283 0         data_dir = "/var/lib/juju"
284
285         # Disabling this prevents `apt-get update` from running initially, so
286         # charms will fail to deploy
287 0         disable_package_commands = False
288
289 0         client_facade = client.ClientFacade.from_connection(connection)
290 0         results = await client_facade.ProvisioningScript(
291             data_dir=data_dir,
292             disable_package_commands=disable_package_commands,
293             machine_id=machine_id,
294             nonce=nonce,
295         )
296
297         """Get the IP of the controller
298
299         Parse the provisioning script, looking for the first apiaddress.
300
301         Example:
302             apiaddresses:
303             - 10.195.8.2:17070
304             - 127.0.0.1:17070
305             - '[::1]:17070'
306         """
307 0         try:
308             # Wait until cloud-init finish
309 0             await self._run_configure_script(CLOUD_INIT_WAIT_SCRIPT)
310 0         except Exception:
311 0             self.log.debug("cloud-init not present in machine {}".format(machine_id))
312
313 0         if proxy:
314 0             m = re.search(
315                 r"apiaddresses:\n- (\d+\.\d+\.\d+\.\d+):17070", results.script
316             )
317 0             apiaddress = m.group(1)
318
319             """Add IP Table rule
320
321             In order to route the traffic to the private ip of the Juju controller
322             we use a DNAT rule to tell the machine that the destination for the
323             private address is the public address of the machine where the Juju
324             controller is running in LXD. That machine will have a complimentary
325             iptables rule, routing traffic to the appropriate LXD container.
326             """
327
328 0             if series and ("centos" in series or "rhel" in series):
329 0                 script = IPTABLES_SCRIPT_RHEL.format(apiaddress, proxy)
330             else:
331 0                 script = IPTABLES_SCRIPT.format(apiaddress, proxy)
332
333             # Run this in a retry loop, because dpkg may be running and cause the
334             # script to fail.
335 0             retry = 10
336 0             attempts = 0
337 0             delay = 15
338
339 0             while attempts <= retry:
340 0                 try:
341 0                     attempts += 1
342 0                     stdout, stderr = await self._run_configure_script(script)
343 0                     break
344 0                 except Exception as e:
345 0                     self.log.debug(
346                         "Waiting for DNAT rules to be applied and saved, "
347                         "sleeping {} seconds".format(delay)
348                     )
349 0                     if attempts > retry:
350 0                         raise e
351                     else:
352 0                         await asyncio.sleep(delay)
353                         # Slowly back off the retry
354 0                         delay += 15
355
356         # self.log.debug("Running configure script")
357 0         await self._run_configure_script(results.script)
358         # self.log.debug("Configure script finished")
359
360 1     async def _run_configure_script(self, script, root=True):
361         """Run the script to install the Juju agent on the target machine.
362
363         :param str script: The script to be executed
364         """
365 0         _, tmpFile = tempfile.mkstemp()
366 0         with open(tmpFile, "w") as f:
367 0             f.write(script)
368 0             f.close()
369
370         # copy the local copy of the script to the remote machine
371 0         await self._scp(tmpFile, tmpFile)
372
373         # run the provisioning script
374 0         return await self._ssh(
375             "{} /bin/bash {}".format("sudo" if root else "", tmpFile)
376         )