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.
17 from subprocess
import CalledProcessError
21 from juju
.client
import client
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"],
34 def normalize_arch(rawArch
):
35 """Normalize the architecture string."""
37 if arch
[0].match(rawArch
):
41 DETECTION_SCRIPT
= """#!/bin/bash
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"
51 grep MemTotal /proc/meminfo
55 INITIALIZE_UBUNTU_SCRIPT
= """set -e
56 (id ubuntu &> /dev/null) || useradd -m ubuntu -s /bin/bash
59 echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > $temp
60 install -m 0440 $temp /etc/sudoers.d/90-juju-ubuntu
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'
69 IPTABLES_SCRIPT
= """#!/bin/bash
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
77 IPTABLES_SCRIPT_RHEL
= """#!/bin/bash
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 {}
89 class AsyncSSHProvisioner
:
90 """Provision a manually created machine via SSH."""
96 def __init__(self
, user
, host
, private_key_path
, log
=None):
99 self
.private_key_path
= private_key_path
100 self
.log
= log
if log
else logging
.getLogger(__name__
)
102 async def _scp(self
, source_file
, destination_file
):
103 """Execute an scp command. Requires a fully qualified source and
106 :param str source_file: Path to the source file
107 :param str destination_file: Path to the destination file
112 os
.path
.expanduser(self
.private_key_path
),
114 "StrictHostKeyChecking=no",
118 destination
= "{}@{}:{}".format(self
.user
, self
.host
, destination_file
)
119 cmd
.extend([source_file
, destination
])
120 process
= await asyncio
.create_subprocess_exec(*cmd
)
122 if process
.returncode
!= 0:
123 raise CalledProcessError(returncode
=process
.returncode
, cmd
=cmd
)
125 async def _ssh(self
, command
):
126 """Run a command remotely via SSH.
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
133 destination
= "{}@{}".format(self
.user
, self
.host
)
137 os
.path
.expanduser(self
.private_key_path
),
139 "StrictHostKeyChecking=no",
143 cmd
.extend([command
])
144 process
= await asyncio
.create_subprocess_exec(
145 *cmd
, stdout
=asyncio
.subprocess
.PIPE
, stderr
=asyncio
.subprocess
.PIPE
147 stdout
, stderr
= await process
.communicate()
149 if process
.returncode
!= 0:
150 output
= stderr
.decode("utf-8").strip()
151 raise CalledProcessError(
152 returncode
=process
.returncode
, cmd
=cmd
, output
=output
154 return (stdout
.decode("utf-8").strip(), stderr
.decode("utf-8").strip())
156 async def _init_ubuntu_user(self
):
157 """Initialize the ubuntu user.
159 :return: bool: If the initialization was successful
160 :raises: :class:`CalledProcessError` if the _ssh command fails
165 while attempts
<= retry
:
168 # Attempt to establish a SSH connection
169 stdout
, stderr
= await self
._ssh
("sudo -n true")
171 except CalledProcessError
as e
:
173 "Waiting for VM to boot, sleeping {} seconds".format(delay
)
178 await asyncio
.sleep(delay
)
179 # Slowly back off the retry
182 # Infer the public key
184 public_key_path
= "{}.pub".format(self
.private_key_path
)
186 if not os
.path
.exists(public_key_path
):
187 raise FileNotFoundError(
188 "Public key '{}' doesn't exist.".format(public_key_path
)
191 with
open(public_key_path
, "r") as f
:
192 public_key
= f
.readline()
194 script
= INITIALIZE_UBUNTU_SCRIPT
.format(public_key
)
196 stdout
, stderr
= await self
._run
_configure
_script
(script
)
200 async def _detect_hardware_and_os(self
):
201 """Detect the target hardware capabilities and OS series.
203 :return: str: A raw string containing OS and hardware information.
213 stdout
, stderr
= await self
._run
_configure
_script
(DETECTION_SCRIPT
)
215 lines
= stdout
.split("\n")
216 info
["series"] = lines
[0].strip()
217 info
["arch"] = normalize_arch(lines
[1].strip())
219 memKb
= re
.split(r
"\s+", lines
[2])[1]
221 # Convert megabytes -> kilobytes
222 info
["mem"] = round(int(memKb
) / 1024)
224 # Detect available CPUs
226 for line
in lines
[3:]:
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()
235 if physical_id
not in recorded
.keys():
236 info
["cpu-cores"] += cores
237 recorded
[physical_id
] = True
241 async def provision_machine(self
):
242 """Perform the initial provisioning of the target machine.
244 :return: bool: The client.AddMachineParams
246 params
= client
.AddMachineParams()
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(
253 self
.host
, str(uuid
.uuid4()),
254 ) # a nop for Juju w/manual machines
255 params
.hardware_characteristics
= {
257 "mem": int(hw
["mem"]),
258 "cpu-cores": int(hw
["cpu-cores"]),
260 params
.addresses
= [{"value": self
.host
, "type": "ipv4", "scope": "public"}]
264 async def install_agent(self
, connection
, nonce
, machine_id
, proxy
=None, series
=None):
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
269 :param str proxy: IP of the API_PROXY
270 :param str series: OS name
272 :return: bool: If the initialization was successful
274 # The path where the Juju agent should be installed.
275 data_dir
= "/var/lib/juju"
277 # Disabling this prevents `apt-get update` from running initially, so
278 # charms will fail to deploy
279 disable_package_commands
= False
281 client_facade
= client
.ClientFacade
.from_connection(connection
)
282 results
= await client_facade
.ProvisioningScript(
284 disable_package_commands
=disable_package_commands
,
285 machine_id
=machine_id
,
289 """Get the IP of the controller
291 Parse the provisioning script, looking for the first apiaddress.
300 m
= re
.search(r
"apiaddresses:\n- (\d+\.\d+\.\d+\.\d+):17070", results
.script
)
301 apiaddress
= m
.group(1)
305 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.
312 if series
and ("centos" in series
or "rhel" in series
):
313 script
= IPTABLES_SCRIPT_RHEL
.format(apiaddress
, proxy
)
315 script
= IPTABLES_SCRIPT
.format(apiaddress
, proxy
)
317 # Run this in a retry loop, because dpkg may be running and cause the
323 while attempts
<= retry
:
326 stdout
, stderr
= await self
._run
_configure
_script
(script
)
328 except Exception as e
:
329 self
.log
.debug("Waiting for DNAT rules to be applied and saved, "
330 "sleeping {} seconds".format(delay
))
334 await asyncio
.sleep(delay
)
335 # Slowly back off the retry
338 # self.log.debug("Running configure script")
339 await self
._run
_configure
_script
(results
.script
)
340 # self.log.debug("Configure script finished")
342 async def _run_configure_script(self
, script
, root
=True):
343 """Run the script to install the Juju agent on the target machine.
345 :param str script: The script to be executed
347 _
, tmpFile
= tempfile
.mkstemp()
348 with
open(tmpFile
, "w") as f
:
352 # copy the local copy of the script to the remote machine
353 await self
._scp
(tmpFile
, tmpFile
)
355 # run the provisioning script
356 return await self
._ssh
(
357 "{} /bin/bash {}".format("sudo" if root
else "", tmpFile
)