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' ]; then
45 os_version=$(grep '^VERSION_ID=' /etc/os-release | tr -d '"' | cut -d= -f2)
46 echo "centos$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
78 class AsyncSSHProvisioner
:
79 """Provision a manually created machine via SSH."""
85 def __init__(self
, user
, host
, private_key_path
, log
=None):
88 self
.private_key_path
= private_key_path
89 self
.log
= log
if log
else logging
.getLogger(__name__
)
91 async def _scp(self
, source_file
, destination_file
):
92 """Execute an scp command. Requires a fully qualified source and
95 :param str source_file: Path to the source file
96 :param str destination_file: Path to the destination file
101 os
.path
.expanduser(self
.private_key_path
),
103 "StrictHostKeyChecking=no",
107 destination
= "{}@{}:{}".format(self
.user
, self
.host
, destination_file
)
108 cmd
.extend([source_file
, destination
])
109 process
= await asyncio
.create_subprocess_exec(*cmd
)
111 if process
.returncode
!= 0:
112 raise CalledProcessError(returncode
=process
.returncode
, cmd
=cmd
)
114 async def _ssh(self
, command
):
115 """Run a command remotely via SSH.
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
122 destination
= "{}@{}".format(self
.user
, self
.host
)
126 os
.path
.expanduser(self
.private_key_path
),
128 "StrictHostKeyChecking=no",
132 cmd
.extend([command
])
133 process
= await asyncio
.create_subprocess_exec(
134 *cmd
, stdout
=asyncio
.subprocess
.PIPE
, stderr
=asyncio
.subprocess
.PIPE
136 stdout
, stderr
= await process
.communicate()
138 if process
.returncode
!= 0:
139 output
= stderr
.decode("utf-8").strip()
140 raise CalledProcessError(
141 returncode
=process
.returncode
, cmd
=cmd
, output
=output
143 return (stdout
.decode("utf-8").strip(), stderr
.decode("utf-8").strip())
145 async def _init_ubuntu_user(self
):
146 """Initialize the ubuntu user.
148 :return: bool: If the initialization was successful
149 :raises: :class:`CalledProcessError` if the _ssh command fails
154 while attempts
<= retry
:
157 # Attempt to establish a SSH connection
158 stdout
, stderr
= await self
._ssh
("sudo -n true")
160 except CalledProcessError
as e
:
162 "Waiting for VM to boot, sleeping {} seconds".format(delay
)
167 await asyncio
.sleep(delay
)
168 # Slowly back off the retry
171 # Infer the public key
173 public_key_path
= "{}.pub".format(self
.private_key_path
)
175 if not os
.path
.exists(public_key_path
):
176 raise FileNotFoundError(
177 "Public key '{}' doesn't exist.".format(public_key_path
)
180 with
open(public_key_path
, "r") as f
:
181 public_key
= f
.readline()
183 script
= INITIALIZE_UBUNTU_SCRIPT
.format(public_key
)
185 stdout
, stderr
= await self
._run
_configure
_script
(script
)
189 async def _detect_hardware_and_os(self
):
190 """Detect the target hardware capabilities and OS series.
192 :return: str: A raw string containing OS and hardware information.
202 stdout
, stderr
= await self
._run
_configure
_script
(DETECTION_SCRIPT
)
204 lines
= stdout
.split("\n")
205 info
["series"] = lines
[0].strip()
206 info
["arch"] = normalize_arch(lines
[1].strip())
208 memKb
= re
.split(r
"\s+", lines
[2])[1]
210 # Convert megabytes -> kilobytes
211 info
["mem"] = round(int(memKb
) / 1024)
213 # Detect available CPUs
215 for line
in lines
[3:]:
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()
224 if physical_id
not in recorded
.keys():
225 info
["cpu-cores"] += cores
226 recorded
[physical_id
] = True
230 async def provision_machine(self
):
231 """Perform the initial provisioning of the target machine.
233 :return: bool: The client.AddMachineParams
235 params
= client
.AddMachineParams()
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
= {
246 "mem": int(hw
["mem"]),
247 "cpu-cores": int(hw
["cpu-cores"]),
249 params
.addresses
= [{"value": self
.host
, "type": "ipv4", "scope": "public"}]
253 async def install_agent(self
, connection
, nonce
, machine_id
, proxy
=None):
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
260 :return: bool: If the initialization was successful
262 # The path where the Juju agent should be installed.
263 data_dir
= "/var/lib/juju"
265 # Disabling this prevents `apt-get update` from running initially, so
266 # charms will fail to deploy
267 disable_package_commands
= False
269 client_facade
= client
.ClientFacade
.from_connection(connection
)
270 results
= await client_facade
.ProvisioningScript(
272 disable_package_commands
=disable_package_commands
,
273 machine_id
=machine_id
,
277 """Get the IP of the controller
279 Parse the provisioning script, looking for the first apiaddress.
288 m
= re
.search(r
"apiaddresses:\n- (\d+\.\d+\.\d+\.\d+):17070", results
.script
)
289 apiaddress
= m
.group(1)
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.
300 script
= IPTABLES_SCRIPT
.format(apiaddress
, proxy
)
302 # Run this in a retry loop, because dpkg may be running and cause the
308 while attempts
<= retry
:
311 stdout
, stderr
= await self
._run
_configure
_script
(script
)
313 except Exception as e
:
314 self
.log
.debug("Waiting for dpkg, sleeping {} seconds".format(delay
))
318 await asyncio
.sleep(delay
)
319 # Slowly back off the retry
322 # self.log.debug("Running configure script")
323 await self
._run
_configure
_script
(results
.script
)
324 # self.log.debug("Configure script finished")
326 async def _run_configure_script(self
, script
, root
=True):
327 """Run the script to install the Juju agent on the target machine.
329 :param str script: The script to be executed
331 _
, tmpFile
= tempfile
.mkstemp()
332 with
open(tmpFile
, "w") as f
:
336 # copy the local copy of the script to the remote machine
337 await self
._scp
(tmpFile
, tmpFile
)
339 # run the provisioning script
340 return await self
._ssh
(
341 "{} /bin/bash {}".format("sudo" if root
else "", tmpFile
)