Remove EntityType from juju watcher and workaround juju bug for retrieving the status
[osm/N2VC.git] / 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 import logging
15 import os
16 import re
17 from subprocess import CalledProcessError
18 import tempfile
19 import uuid
20
21 from juju.client import client
22 import asyncio
23
24 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 def normalize_arch(rawArch):
35 """Normalize the architecture string."""
36 for arch in arches:
37 if arch[0].match(rawArch):
38 return arch[1]
39
40
41 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' ]; then
45 os_version=$(grep '^VERSION_ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
46 echo "centos$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 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 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
78 class AsyncSSHProvisioner:
79 """Provision a manually created machine via SSH."""
80
81 user = ""
82 host = ""
83 private_key_path = ""
84
85 def __init__(self, user, host, private_key_path, log=None):
86 self.host = host
87 self.user = user
88 self.private_key_path = private_key_path
89 self.log = log if log else logging.getLogger(__name__)
90
91 async def _scp(self, source_file, destination_file):
92 """Execute an scp command. Requires a fully qualified source and
93 destination.
94
95 :param str source_file: Path to the source file
96 :param str destination_file: Path to the destination file
97 """
98 cmd = [
99 "scp",
100 "-i",
101 os.path.expanduser(self.private_key_path),
102 "-o",
103 "StrictHostKeyChecking=no",
104 "-q",
105 "-B",
106 ]
107 destination = "{}@{}:{}".format(self.user, self.host, destination_file)
108 cmd.extend([source_file, destination])
109 process = await asyncio.create_subprocess_exec(*cmd)
110 await process.wait()
111 if process.returncode != 0:
112 raise CalledProcessError(returncode=process.returncode, cmd=cmd)
113
114 async def _ssh(self, command):
115 """Run a command remotely via SSH.
116
117 :param str command: The command to execute
118 :return: tuple: The stdout and stderr of the command execution
119 :raises: :class:`CalledProcessError` if the command fails
120 """
121
122 destination = "{}@{}".format(self.user, self.host)
123 cmd = [
124 "ssh",
125 "-i",
126 os.path.expanduser(self.private_key_path),
127 "-o",
128 "StrictHostKeyChecking=no",
129 "-q",
130 destination,
131 ]
132 cmd.extend([command])
133 process = await asyncio.create_subprocess_exec(
134 *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
135 )
136 stdout, stderr = await process.communicate()
137
138 if process.returncode != 0:
139 output = stderr.decode("utf-8").strip()
140 raise CalledProcessError(
141 returncode=process.returncode, cmd=cmd, output=output
142 )
143 return (stdout.decode("utf-8").strip(), stderr.decode("utf-8").strip())
144
145 async def _init_ubuntu_user(self):
146 """Initialize the ubuntu user.
147
148 :return: bool: If the initialization was successful
149 :raises: :class:`CalledProcessError` if the _ssh command fails
150 """
151 retry = 10
152 attempts = 0
153 delay = 15
154 while attempts <= retry:
155 try:
156 attempts += 1
157 # Attempt to establish a SSH connection
158 stdout, stderr = await self._ssh("sudo -n true")
159 break
160 except CalledProcessError as e:
161 self.log.debug(
162 "Waiting for VM to boot, sleeping {} seconds".format(delay)
163 )
164 if attempts > retry:
165 raise e
166 else:
167 await asyncio.sleep(delay)
168 # Slowly back off the retry
169 delay += 15
170
171 # Infer the public key
172 public_key = None
173 public_key_path = "{}.pub".format(self.private_key_path)
174
175 if not os.path.exists(public_key_path):
176 raise FileNotFoundError(
177 "Public key '{}' doesn't exist.".format(public_key_path)
178 )
179
180 with open(public_key_path, "r") as f:
181 public_key = f.readline()
182
183 script = INITIALIZE_UBUNTU_SCRIPT.format(public_key)
184
185 stdout, stderr = await self._run_configure_script(script)
186
187 return True
188
189 async def _detect_hardware_and_os(self):
190 """Detect the target hardware capabilities and OS series.
191
192 :return: str: A raw string containing OS and hardware information.
193 """
194
195 info = {
196 "series": "",
197 "arch": "",
198 "cpu-cores": "",
199 "mem": "",
200 }
201
202 stdout, stderr = await self._run_configure_script(DETECTION_SCRIPT)
203
204 lines = stdout.split("\n")
205 info["series"] = lines[0].strip()
206 info["arch"] = normalize_arch(lines[1].strip())
207
208 memKb = re.split(r"\s+", lines[2])[1]
209
210 # Convert megabytes -> kilobytes
211 info["mem"] = round(int(memKb) / 1024)
212
213 # Detect available CPUs
214 recorded = {}
215 for line in lines[3:]:
216 physical_id = ""
217 print(line)
218
219 if line.find("physical id") == 0:
220 physical_id = line.split(":")[1].strip()
221 elif line.find("cpu cores") == 0:
222 cores = line.split(":")[1].strip()
223
224 if physical_id not in recorded.keys():
225 info["cpu-cores"] += cores
226 recorded[physical_id] = True
227
228 return info
229
230 async def provision_machine(self):
231 """Perform the initial provisioning of the target machine.
232
233 :return: bool: The client.AddMachineParams
234 """
235 params = client.AddMachineParams()
236
237 if await self._init_ubuntu_user():
238 hw = await self._detect_hardware_and_os()
239 params.series = hw["series"]
240 params.instance_id = "manual:{}".format(self.host)
241 params.nonce = "manual:{}:{}".format(
242 self.host, str(uuid.uuid4()),
243 ) # a nop for Juju w/manual machines
244 params.hardware_characteristics = {
245 "arch": hw["arch"],
246 "mem": int(hw["mem"]),
247 "cpu-cores": int(hw["cpu-cores"]),
248 }
249 params.addresses = [{"value": self.host, "type": "ipv4", "scope": "public"}]
250
251 return params
252
253 async def install_agent(self, connection, nonce, machine_id, proxy=None):
254 """
255 :param object connection: Connection to Juju API
256 :param str nonce: The nonce machine specification
257 :param str machine_id: The id assigned to the machine
258 :param str proxy: IP of the API_PROXY
259
260 :return: bool: If the initialization was successful
261 """
262 # The path where the Juju agent should be installed.
263 data_dir = "/var/lib/juju"
264
265 # Disabling this prevents `apt-get update` from running initially, so
266 # charms will fail to deploy
267 disable_package_commands = False
268
269 client_facade = client.ClientFacade.from_connection(connection)
270 results = await client_facade.ProvisioningScript(
271 data_dir=data_dir,
272 disable_package_commands=disable_package_commands,
273 machine_id=machine_id,
274 nonce=nonce,
275 )
276
277 """Get the IP of the controller
278
279 Parse the provisioning script, looking for the first apiaddress.
280
281 Example:
282 apiaddresses:
283 - 10.195.8.2:17070
284 - 127.0.0.1:17070
285 - '[::1]:17070'
286 """
287 if proxy:
288 m = re.search(r"apiaddresses:\n- (\d+\.\d+\.\d+\.\d+):17070", results.script)
289 apiaddress = m.group(1)
290
291 """Add IP Table rule
292
293 In order to route the traffic to the private ip of the Juju controller
294 we use a DNAT rule to tell the machine that the destination for the
295 private address is the public address of the machine where the Juju
296 controller is running in LXD. That machine will have a complimentary
297 iptables rule, routing traffic to the appropriate LXD container.
298 """
299
300 script = IPTABLES_SCRIPT.format(apiaddress, proxy)
301
302 # Run this in a retry loop, because dpkg may be running and cause the
303 # script to fail.
304 retry = 10
305 attempts = 0
306 delay = 15
307
308 while attempts <= retry:
309 try:
310 attempts += 1
311 stdout, stderr = await self._run_configure_script(script)
312 break
313 except Exception as e:
314 self.log.debug("Waiting for dpkg, sleeping {} seconds".format(delay))
315 if attempts > retry:
316 raise e
317 else:
318 await asyncio.sleep(delay)
319 # Slowly back off the retry
320 delay += 15
321
322 # self.log.debug("Running configure script")
323 await self._run_configure_script(results.script)
324 # self.log.debug("Configure script finished")
325
326 async def _run_configure_script(self, script, root=True):
327 """Run the script to install the Juju agent on the target machine.
328
329 :param str script: The script to be executed
330 """
331 _, tmpFile = tempfile.mkstemp()
332 with open(tmpFile, "w") as f:
333 f.write(script)
334 f.close()
335
336 # copy the local copy of the script to the remote machine
337 await self._scp(tmpFile, tmpFile)
338
339 # run the provisioning script
340 return await self._ssh(
341 "{} /bin/bash {}".format("sudo" if root else "", tmpFile)
342 )