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 {}
88 CLOUD_INIT_WAIT_SCRIPT
= """#!/bin/bash
90 cloud-init status --wait
94 class AsyncSSHProvisioner
:
95 """Provision a manually created machine via SSH."""
101 def __init__(self
, user
, host
, private_key_path
, log
=None):
104 self
.private_key_path
= private_key_path
105 self
.log
= log
if log
else logging
.getLogger(__name__
)
107 async def _scp(self
, source_file
, destination_file
):
108 """Execute an scp command. Requires a fully qualified source and
111 :param str source_file: Path to the source file
112 :param str destination_file: Path to the destination file
117 os
.path
.expanduser(self
.private_key_path
),
119 "StrictHostKeyChecking=no",
123 destination
= "{}@{}:{}".format(self
.user
, self
.host
, destination_file
)
124 cmd
.extend([source_file
, destination
])
125 process
= await asyncio
.create_subprocess_exec(*cmd
)
127 if process
.returncode
!= 0:
128 raise CalledProcessError(returncode
=process
.returncode
, cmd
=cmd
)
130 async def _ssh(self
, command
):
131 """Run a command remotely via SSH.
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
138 destination
= "{}@{}".format(self
.user
, self
.host
)
142 os
.path
.expanduser(self
.private_key_path
),
144 "StrictHostKeyChecking=no",
148 cmd
.extend([command
])
149 process
= await asyncio
.create_subprocess_exec(
150 *cmd
, stdout
=asyncio
.subprocess
.PIPE
, stderr
=asyncio
.subprocess
.PIPE
152 stdout
, stderr
= await process
.communicate()
154 if process
.returncode
!= 0:
155 output
= stderr
.decode("utf-8").strip()
156 raise CalledProcessError(
157 returncode
=process
.returncode
, cmd
=cmd
, output
=output
159 return (stdout
.decode("utf-8").strip(), stderr
.decode("utf-8").strip())
161 async def _init_ubuntu_user(self
):
162 """Initialize the ubuntu user.
164 :return: bool: If the initialization was successful
165 :raises: :class:`CalledProcessError` if the _ssh command fails
170 while attempts
<= retry
:
173 # Attempt to establish a SSH connection
174 stdout
, stderr
= await self
._ssh
("sudo -n true")
176 except CalledProcessError
as e
:
178 "Waiting for VM to boot, sleeping {} seconds".format(delay
)
183 await asyncio
.sleep(delay
)
184 # Slowly back off the retry
187 # Infer the public key
189 public_key_path
= "{}.pub".format(self
.private_key_path
)
191 if not os
.path
.exists(public_key_path
):
192 raise FileNotFoundError(
193 "Public key '{}' doesn't exist.".format(public_key_path
)
196 with
open(public_key_path
, "r") as f
:
197 public_key
= f
.readline()
199 script
= INITIALIZE_UBUNTU_SCRIPT
.format(public_key
)
201 stdout
, stderr
= await self
._run
_configure
_script
(script
)
205 async def _detect_hardware_and_os(self
):
206 """Detect the target hardware capabilities and OS series.
208 :return: str: A raw string containing OS and hardware information.
218 stdout
, stderr
= await self
._run
_configure
_script
(DETECTION_SCRIPT
)
220 lines
= stdout
.split("\n")
221 info
["series"] = lines
[0].strip()
222 info
["arch"] = normalize_arch(lines
[1].strip())
224 memKb
= re
.split(r
"\s+", lines
[2])[1]
226 # Convert megabytes -> kilobytes
227 info
["mem"] = round(int(memKb
) / 1024)
229 # Detect available CPUs
231 for line
in lines
[3:]:
235 if line
.find("physical id") == 0:
236 physical_id
= line
.split(":")[1].strip()
237 elif line
.find("cpu cores") == 0:
238 cores
= line
.split(":")[1].strip()
240 if physical_id
not in recorded
.keys():
241 info
["cpu-cores"] += cores
242 recorded
[physical_id
] = True
246 async def provision_machine(self
):
247 """Perform the initial provisioning of the target machine.
249 :return: bool: The client.AddMachineParams
251 params
= client
.AddMachineParams()
253 if await self
._init
_ubuntu
_user
():
254 hw
= await self
._detect
_hardware
_and
_os
()
255 params
.series
= hw
["series"]
256 params
.instance_id
= "manual:{}".format(self
.host
)
257 params
.nonce
= "manual:{}:{}".format(
260 ) # a nop for Juju w/manual machines
261 params
.hardware_characteristics
= {
263 "mem": int(hw
["mem"]),
264 "cpu-cores": int(hw
["cpu-cores"]),
266 params
.addresses
= [{"value": self
.host
, "type": "ipv4", "scope": "public"}]
270 async def install_agent(
271 self
, connection
, nonce
, machine_id
, proxy
=None, series
=None
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
280 :return: bool: If the initialization was successful
282 # The path where the Juju agent should be installed.
283 data_dir
= "/var/lib/juju"
285 # Disabling this prevents `apt-get update` from running initially, so
286 # charms will fail to deploy
287 disable_package_commands
= False
289 client_facade
= client
.ClientFacade
.from_connection(connection
)
290 results
= await client_facade
.ProvisioningScript(
292 disable_package_commands
=disable_package_commands
,
293 machine_id
=machine_id
,
297 """Get the IP of the controller
299 Parse the provisioning script, looking for the first apiaddress.
308 # Wait until cloud-init finish
309 await self
._run
_configure
_script
(CLOUD_INIT_WAIT_SCRIPT
)
311 self
.log
.debug("cloud-init not present in machine {}".format(machine_id
))
315 r
"apiaddresses:\n- (\d+\.\d+\.\d+\.\d+):17070", results
.script
317 apiaddress
= m
.group(1)
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.
328 if series
and ("centos" in series
or "rhel" in series
):
329 script
= IPTABLES_SCRIPT_RHEL
.format(apiaddress
, proxy
)
331 script
= IPTABLES_SCRIPT
.format(apiaddress
, proxy
)
333 # Run this in a retry loop, because dpkg may be running and cause the
339 while attempts
<= retry
:
342 stdout
, stderr
= await self
._run
_configure
_script
(script
)
344 except Exception as e
:
346 "Waiting for DNAT rules to be applied and saved, "
347 "sleeping {} seconds".format(delay
)
352 await asyncio
.sleep(delay
)
353 # Slowly back off the retry
356 # self.log.debug("Running configure script")
357 await self
._run
_configure
_script
(results
.script
)
358 # self.log.debug("Configure script finished")
360 async def _run_configure_script(self
, script
, root
=True):
361 """Run the script to install the Juju agent on the target machine.
363 :param str script: The script to be executed
365 _
, tmpFile
= tempfile
.mkstemp()
366 with
open(tmpFile
, "w") as f
:
370 # copy the local copy of the script to the remote machine
371 await self
._scp
(tmpFile
, tmpFile
)
373 # run the provisioning script
374 return await self
._ssh
(
375 "{} /bin/bash {}".format("sudo" if root
else "", tmpFile
)