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