Merge lp:~larryprice/libertine/libertine-shell-ssh into lp:libertine
- libertine-shell-ssh
- Merge into devel
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Christopher Townsend | ||||
Approved revision: | 423 | ||||
Merged at revision: | 459 | ||||
Proposed branch: | lp:~larryprice/libertine/libertine-shell-ssh | ||||
Merge into: | lp:libertine | ||||
Diff against target: |
373 lines (+337/-3) 5 files modified
debian/libertine-tools.install (+3/-0) tools/CMakeLists.txt (+3/-3) tools/completions/libertine-shell (+50/-0) tools/libertine-shell (+255/-0) tools/libertine-shell.1 (+26/-0) |
||||
To merge this branch: | bzr merge lp:~larryprice/libertine/libertine-shell-ssh | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Christopher Townsend | Approve | ||
Libertine CI Bot | continuous-integration | Approve | |
Review via email: mp+319369@code.launchpad.net |
Commit message
Add tool to easily ssh into libertine containers.
Description of the change
Add tool to easily ssh into libertine containers.
Libertine CI Bot (libertine-ci-bot) wrote : | # |
- 417. By Larry Price
-
how did i forget to add libertine-shell to install...
Libertine CI Bot (libertine-ci-bot) wrote : | # |
PASSED: Continuous integration, rev:417
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
Click here to trigger a rebuild:
https:/
- 418. By Larry Price
-
prevent non-lx* containers from using libertine-shell
- 419. By Larry Price
-
merge
Libertine CI Bot (libertine-ci-bot) wrote : | # |
PASSED: Continuous integration, rev:419
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
Click here to trigger a rebuild:
https:/
- 420. By Larry Price
-
merge
Libertine CI Bot (libertine-ci-bot) wrote : | # |
PASSED: Continuous integration, rev:420
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
Click here to trigger a rebuild:
https:/
Christopher Townsend (townsend) wrote : | # |
I built the packages, installed them, and rebooted the system. I then logged in to U8, and this is what I get when running libertine-shell:
$ libertine-shell -i test-lxd
Traceback (most recent call last):
File "/usr/bin/
main()
File "/usr/bin/
with open(os.
FileNotFoundError: [Errno 2] No such file or directory: '/home/
- 421. By Larry Price
-
use bind mounted dir if necessary
Libertine CI Bot (libertine-ci-bot) wrote : | # |
PASSED: Continuous integration, rev:421
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
Click here to trigger a rebuild:
https:/
Christopher Townsend (townsend) wrote : | # |
Ok, next issue:)
I'm trying this on a machine that I have never used ssh on. I get the following error:
$ libertine-shell -i test-lxd
Traceback (most recent call last):
File "/usr/bin/
main()
File "/usr/bin/
config.
File "/usr/bin/
all_lines, host_line = self._ssh_
File "/usr/bin/
with open(config_path, 'r') as f:
FileNotFoundError: [Errno 2] No such file or directory: '/home/
- 422. By Larry Price
-
don't read non-existent config
Libertine CI Bot (libertine-ci-bot) wrote : | # |
PASSED: Continuous integration, rev:422
https:/
Executed test runs:
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
SUCCESS: https:/
deb: https:/
Click here to trigger a rebuild:
https:/
- 423. By Larry Price
-
specify container type in help/manpage
Christopher Townsend (townsend) wrote : | # |
Ok, works fine. +1
Preview Diff
1 | === modified file 'debian/libertine-tools.install' |
2 | --- debian/libertine-tools.install 2017-03-13 15:50:54 +0000 |
3 | +++ debian/libertine-tools.install 2017-04-07 13:51:54 +0000 |
4 | @@ -1,3 +1,6 @@ |
5 | usr/bin/libertine-container-manager |
6 | +usr/bin/libertine-shell |
7 | usr/share/bash-completion/completions/libertine-container-manager |
8 | usr/share/man/*/libertine-container-manager.1 |
9 | +usr/share/bash-completion/completions/libertine-shell |
10 | +usr/share/man/*/libertine-shell.1 |
11 | |
12 | === modified file 'tools/CMakeLists.txt' |
13 | --- tools/CMakeLists.txt 2017-01-31 16:28:31 +0000 |
14 | +++ tools/CMakeLists.txt 2017-04-07 13:51:54 +0000 |
15 | @@ -1,9 +1,9 @@ |
16 | -install(PROGRAMS libertine-container-manager libertine-launch |
17 | +install(PROGRAMS libertine-container-manager libertine-launch libertine-shell |
18 | libertine-xmir libertine-lxc-setup libertine-lxd-setup libertined |
19 | DESTINATION ${CMAKE_INSTALL_BINDIR}) |
20 | install(FILES libertine-launch.1 libertine-container-manager.1 |
21 | - libertine-xmir.1 |
22 | + libertine-xmir.1 libertine-shell.1 |
23 | DESTINATION ${CMAKE_INSTALL_MANDIR}/man1 |
24 | COMPONENT doc) |
25 | -install(FILES completions/libertine-container-manager |
26 | +install(FILES completions/libertine-container-manager completions/libertine-shell |
27 | DESTINATION ${DESTDIR}/usr/share/bash-completion/completions/) |
28 | |
29 | === added file 'tools/completions/libertine-shell' |
30 | --- tools/completions/libertine-shell 1970-01-01 00:00:00 +0000 |
31 | +++ tools/completions/libertine-shell 2017-04-07 13:51:54 +0000 |
32 | @@ -0,0 +1,50 @@ |
33 | +# Copyright (C) 2017 Canonical Ltd. |
34 | +# |
35 | +# This program is free software: you can redistribute it and/or modify |
36 | +# it under the terms of the GNU General Public License as published by |
37 | +# the Free Software Foundation; version 3 of the License. |
38 | +# |
39 | +# This program is distributed in the hope that it will be useful, |
40 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
41 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
42 | +# GNU General Public License for more details. |
43 | +# |
44 | +# You should have received a copy of the GNU General Public License |
45 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
46 | + |
47 | + |
48 | +_libertine-shell() |
49 | +{ |
50 | + local cur cmd opts |
51 | + COMPREPLY=() |
52 | + cur=${COMP_WORDS[COMP_CWORD]} |
53 | + |
54 | + if [[ ${COMP_CWORD} -gt 1 ]]; then |
55 | + cmd="$cur" |
56 | + if [[ ${cmd} != -* ]]; then |
57 | + cmd=${COMP_WORDS[COMP_CWORD-1]} |
58 | + fi |
59 | + |
60 | + case "${cmd}" in |
61 | + "--username" ) |
62 | + opts=$(users) |
63 | + ;; |
64 | + "--identity-file" ) |
65 | + compopt -o filenames |
66 | + local files=("${cur}"*) |
67 | + [[ -e ${files[0]} ]] && COMPREPLY=( "${files[@]// /\ }" ) |
68 | + return 0 |
69 | + ;; |
70 | + esac |
71 | + fi |
72 | + |
73 | + if [[ -z ${opts} ]]; then |
74 | + opts="--help --identity-file --username --assume-yes --id" |
75 | + fi |
76 | + |
77 | + if [[ -n "${opts}" ]]; then |
78 | + COMPREPLY=( $(compgen -W "${opts}" -- "${COMP_WORDS[COMP_CWORD]}") ) |
79 | + return 0 |
80 | + fi |
81 | +} |
82 | +complete -F _libertine-shell libertine-shell |
83 | |
84 | === added file 'tools/libertine-shell' |
85 | --- tools/libertine-shell 1970-01-01 00:00:00 +0000 |
86 | +++ tools/libertine-shell 2017-04-07 13:51:54 +0000 |
87 | @@ -0,0 +1,255 @@ |
88 | +#!/usr/bin/python3 |
89 | +# -*- coding: utf-8 -*- |
90 | +# |
91 | +# Copyright (C) 2017 Canonical Ltd. |
92 | +# |
93 | +# This program is free software: you can redistribute it and/or modify |
94 | +# it under the terms of the GNU General Public License as published by |
95 | +# the Free Software Foundation; version 3 of the License. |
96 | +# |
97 | +# This program is distributed in the hope that it will be useful, |
98 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
99 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
100 | +# GNU General Public License for more details. |
101 | +# |
102 | +# You should have received a copy of the GNU General Public License |
103 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
104 | + |
105 | +import argparse |
106 | +import os |
107 | +import shlex |
108 | +import subprocess |
109 | +import sys |
110 | + |
111 | +from libertine import ContainersConfig, utils, Libertine |
112 | +from shutil import copyfile |
113 | + |
114 | +import gettext |
115 | +gettext.textdomain('libertine') |
116 | +_ = gettext.gettext |
117 | + |
118 | +class ShellConfig(object): |
119 | + def __init__(self, args): |
120 | + super().__init__() |
121 | + self._validate(self._parse(args)) |
122 | + |
123 | + @property |
124 | + def public_key(self): |
125 | + return "{}.pub".format(self.identity_file) |
126 | + |
127 | + def _parse(self, args): |
128 | + arg_parser = argparse.ArgumentParser(description=_('Launch an SSH session within a lxc/lxd Libertine container')) |
129 | + arg_parser.add_argument('-i', '--id', |
130 | + help=_('Container identifier')) |
131 | + arg_parser.add_argument('-u', '--username', |
132 | + help=_('Container username')) |
133 | + arg_parser.add_argument('-f', '--identity-file', |
134 | + help=_('SSH key to be used')) |
135 | + arg_parser.add_argument('-y', '--assume-yes', |
136 | + action='store_true', |
137 | + help=_('Assume yes to all prompts')) |
138 | + |
139 | + return arg_parser.parse_args(args=args) |
140 | + |
141 | + def _validate(self, options): |
142 | + self.assume_yes = options.assume_yes or False |
143 | + self.username = options.username |
144 | + |
145 | + config = ContainersConfig.ContainersConfig() |
146 | + self.container_id = config.check_container_id(options.id) |
147 | + self.container_type = config.get_container_type(self.container_id) |
148 | + utils.get_logger().debug("Using Container ID: {}".format(self.container_id)) |
149 | + |
150 | + self.identity_file = None |
151 | + |
152 | + if options.identity_file: |
153 | + utils.get_logger().debug("Public key file path: {}".format(options.identity_file)) |
154 | + |
155 | + if not os.path.exists(options.identity_file): |
156 | + if not os.path.dirname(options.identity_file) and os.path.exists(os.path.join(os.environ['HOME'], '.ssh', options.identity_file)): |
157 | + self.identity_file = os.path.join(os.environ['HOME'], '.ssh', options.identity_file) |
158 | + else: |
159 | + utils.get_logger().error(_("Identity file not found at '{}'. Leave blank for default.").format(options.identity_file)) |
160 | + sys.exit(1) |
161 | + else: |
162 | + self.identity_file = options.identity_file |
163 | + |
164 | + if not os.path.exists(self.public_key): |
165 | + utils.get_logger().error(_("Corresponding public key not found for '{}'.").format(self.identity_file)) |
166 | + sys.exit(1) |
167 | + |
168 | + def setup_public_keys(self, hostname): |
169 | + should_save = False |
170 | + all_lines, host_line = self._ssh_config_lines(hostname) |
171 | + |
172 | + if host_line: |
173 | + identity_files = [line for line in host_line.split('\n') if "IdentityFile " in line] |
174 | + if identity_files: |
175 | + if not self.identity_file: |
176 | + self.identity_file = identity_files[-1].split(' ')[-1] |
177 | + elif not [i for i in identity_files if self.identity_file in i]: |
178 | + should_save = True |
179 | + host_line += "\n\tIdentityFile {}".format(self.identity_file) |
180 | + if self.username and not "User {}".format(self.username) in host_line: |
181 | + host_line += "\tUser {}\n".format(self.username) |
182 | + elif self.identity_file: |
183 | + should_save = True |
184 | + host_line = "Host {}\n\tIdentityFile {}".format(hostname, self.identity_file) |
185 | + all_lines.append(host_line) |
186 | + |
187 | + if host_line and self.username and not "User {}".format(self.username) in host_line: |
188 | + should_save = True |
189 | + host_line += "\n\tUser {}\n".format(self.username) |
190 | + |
191 | + if not self.identity_file: |
192 | + DEFAULT_KEYS = ["id_dsa", "id_rsa", "id_ecdsa", "id_ed25519"] |
193 | + keys = [key for key in os.listdir(os.path.join(os.environ['HOME'], '.ssh')) if key in DEFAULT_KEYS] |
194 | + if keys: |
195 | + self.identity_file = os.path.join(os.environ['HOME'], '.ssh', keys[0]) |
196 | + |
197 | + if self.identity_file and not (os.path.exists(self.identity_file) or os.path.exists(self.public_key)): |
198 | + utils.get_logger().error(_("Configured identity file or public key matching '{}' do not exist.").format(self.identity_file)) |
199 | + sys.exit(1) |
200 | + |
201 | + if should_save and self._ask_save_ssh_config(): |
202 | + try: |
203 | + config_path = os.path.join(os.environ['HOME'], ".ssh", "config") |
204 | + copyfile(config_path, config_path + ".bak") |
205 | + with open(config_path, 'w') as f: |
206 | + for i in range(0, len(all_lines)): |
207 | + if i == (len(all_lines)-1): |
208 | + newlines = '\n' |
209 | + else: |
210 | + newlines = '\n\n' |
211 | + |
212 | + if not all_lines[i].startswith("Host {}".format(hostname)): |
213 | + f.write(all_lines[i] + newlines) |
214 | + else: |
215 | + f.write(host_line + newlines) |
216 | + except Exception as e: |
217 | + utils.get_logger().warning("Error caught during config edit: {}.".format(str(e))) |
218 | + utils.get_logger().warning("Restoring previous version of '{}' and continuing".format(config_path)) |
219 | + copyfile(config_path + ".bak", config_path) |
220 | + |
221 | + os.remove(config_path + ".bak") |
222 | + |
223 | + def _ssh_config_lines(self, hostname): |
224 | + ssh_dir = os.path.join(os.environ['HOME'], ".ssh") |
225 | + if not os.path.exists(ssh_dir): |
226 | + utils.get_logger().error(_("It looks like no SSH keys are set up. Please generate a key and try again. " |
227 | + "You can use the following command to generate an appropriate key:\n" |
228 | + "\tssh-keygen -t rsa -b 4096 -C 'your_email@example.com'")) |
229 | + sys.exit(1) |
230 | + |
231 | + config_path = os.path.join(ssh_dir, "config") |
232 | + |
233 | + all_lines = [] |
234 | + host_line = [] |
235 | + if os.path.exists(config_path): |
236 | + with open(config_path, 'r') as f: |
237 | + all_lines = [line.strip() for line in f.read().split('\n\n')] |
238 | + host_line = [line for line in all_lines if line.startswith("Host {}".format(hostname))] |
239 | + if host_line: |
240 | + host_line = host_line[-1] |
241 | + |
242 | + return (all_lines, host_line or '') |
243 | + |
244 | + def _ask_save_ssh_config(self): |
245 | + if not self.assume_yes: |
246 | + if self.username: |
247 | + prompt = input(_("Always use '{}' as identity file and username '{}' " |
248 | + "when connecting to '{}'? [Yn]").format(self.identity_file, self.username, self.container_id)) |
249 | + else: |
250 | + prompt = input(_("Always use '{}' as identity file when connecting to '{}'? [Yn]").format(self.identity_file, self.container_id)) |
251 | + |
252 | + if not (prompt == _('Y') or prompt == _('y') or prompt == ''): |
253 | + utils.get_logger().debug("Responded 'no' to saving identity configuration") |
254 | + return False |
255 | + |
256 | + return True |
257 | + |
258 | + def command(self, hostname): |
259 | + if self.username: |
260 | + hostname = "{}@{}".format(self.username, hostname) |
261 | + |
262 | + options = "" |
263 | + if self.identity_file: |
264 | + options += " -i {} ".format(self.identity_file) |
265 | + |
266 | + return "ssh {} {} -t 'bash -s'".format(options, hostname) |
267 | + |
268 | + def get_ip_host_file_path(self, home): |
269 | + if home in ContainersConfig.ContainersConfig().get_container_bind_mounts(self.container_id): |
270 | + return os.path.join(home, 'libertine-shell-found-ip') |
271 | + |
272 | + return os.path.join(utils.get_libertine_container_home_dir(self.container_id), 'libertine-shell-found-ip') |
273 | + |
274 | + |
275 | +def main(): |
276 | + if subprocess.Popen(shlex.split("bash -c \"which sshd &> /dev/null\"")).wait() != 0: |
277 | + utils.get_logger().error(_("No sshd found. You can install openssh with the following command:\n" |
278 | + "\tapt install openssh-client")) |
279 | + sys.exit(1) |
280 | + |
281 | + config = ShellConfig(sys.argv[1:]) |
282 | + |
283 | + if not (config.container_type == 'lxd' or config.container_type == 'lxc'): |
284 | + utils.get_logger().error(_("'{}' is a '{}' container. Only 'lxd' or 'lxc' " |
285 | + "containers are able to use this tool.".format( |
286 | + config.container_id, config.container_type))) |
287 | + sys.exit(1) |
288 | + |
289 | + container = Libertine.LibertineContainer(config.container_id) |
290 | + |
291 | + |
292 | + with Libertine.ContainerRunning(container.container): |
293 | + if not container.container._binary_exists("sshd"): |
294 | + if not config.assume_yes: |
295 | + prompt = input(_("openssh-server not detected in container '{}'. Install now? [Yn]").format(config.container_id)) |
296 | + if not (prompt == _('Y') or prompt == _('y') or prompt == ''): |
297 | + utils.get_logger().debug("Responded 'no' to openssh-server installation") |
298 | + sys.exit(1) |
299 | + |
300 | + if not container.install_package("openssh-server") or not container.exec_command("update-rc.d ssh defaults"): |
301 | + utils.get_logger().error(_("Failed to install openssh-server")) |
302 | + sys.exit(1) |
303 | + |
304 | + username = config.username or os.environ['USER'] |
305 | + |
306 | + if not container.exec_command('bash -c "hostname -I | awk \'{ print $1 }\' > /home/%s/libertine-shell-found-ip"' % username): |
307 | + utils.get_logger().error(_("Unable to get IP address for '{}'".format(config.container_id))) |
308 | + sys.exit(1) |
309 | + |
310 | + with open(config.get_ip_host_file_path('/home/%s' % username)) as f: |
311 | + hostname = f.read().strip() |
312 | + utils.get_logger().debug(hostname) |
313 | + if not hostname: |
314 | + utils.get_logger().error(_("Unable to get IP address for '{}'".format(config.container_id))) |
315 | + sys.exit(1) |
316 | + |
317 | + config.setup_public_keys(hostname) |
318 | + |
319 | + if not container.exec_command('test -e /home/{}/.ssh/authorized_keys'.format(username)): |
320 | + container.exec_command("bash -c 'mkdir -p /home/{user}/.ssh && " |
321 | + "chown {user}:{user} /home/{user}/.ssh'".format(user=username)) |
322 | + container.exec_command("bash -c 'touch /home/{user}/.ssh/authorized_keys && " |
323 | + "chown {user}:{user} /home/{user}/.ssh/authorized_keys'".format(user=username)) |
324 | + |
325 | + with open(config.public_key, 'r') as f: |
326 | + public_key = f.read().strip() |
327 | + if not container.exec_command('grep -q "{}$" /home/{}/.ssh/authorized_keys'.format(public_key, username)): |
328 | + if not config.assume_yes: |
329 | + prompt = input(_("OK to add public key '{}' to container '{}'? [Yn]").format(config.public_key, config.container_id)) |
330 | + if not (prompt == _('Y') or prompt == _('y') or prompt == ''): |
331 | + utils.get_logger().error(_("Public key must be added to container to continue.")) |
332 | + sys.exit(1) |
333 | + |
334 | + if not container.exec_command('bash -c "echo {} >> /home/{}/.ssh/authorized_keys"'.format(public_key, username)): |
335 | + utils.get_logger().error(_("Failed to add public key to container's authorized keys.")) |
336 | + sys.exit(1) |
337 | + |
338 | + subprocess.call(shlex.split(config.command(hostname))) |
339 | + |
340 | + |
341 | +if __name__ == '__main__': |
342 | + main() |
343 | |
344 | === added file 'tools/libertine-shell.1' |
345 | --- tools/libertine-shell.1 1970-01-01 00:00:00 +0000 |
346 | +++ tools/libertine-shell.1 2017-04-07 13:51:54 +0000 |
347 | @@ -0,0 +1,26 @@ |
348 | +.TH libertine-shell "1" "March 2017" "libertine-shell 1.0" "User Commands" |
349 | + |
350 | +.SH NAME |
351 | +libertine-shell \- Launch an SSH session connected to a lxc/lxd Libertine container |
352 | + |
353 | +.SH DESCRIPTION |
354 | +usage: libertine\-shell [\-h] [-i ID] [-u USER] [-f IDENTITY_FILE_PATH] [-y] |
355 | +.PP |
356 | +Launch an SSH session connected to a Libertine container |
357 | + |
358 | +.SS "optional arguments:" |
359 | +.TP |
360 | +\fB\-h\fR, \fB\-\-help\fR |
361 | +show this help message and exit |
362 | +.TP |
363 | +\fB\-i\fR, \fB\-\-id\fR |
364 | +Container identifier (default container if none specified) |
365 | +.TP |
366 | +\fB\-u\fR, \fB\-\-username\fR |
367 | +Container username (current username if none specified) |
368 | +.TP |
369 | +\fB\-f\fR, \fB\-\-identity-file\fR |
370 | +Full path of SSH key to be used |
371 | +.TP |
372 | +\fB\-y\fR, \fB\-\-assume-yes\fR |
373 | +Assume yes to all prompts (installations, public key copy, configuration changes) |
FAILED: Continuous integration, rev:416 /jenkins. canonical. com/libertine/ job/lp- libertine- ci/452/ /jenkins. canonical. com/libertine/ job/build/ 837/console /jenkins. canonical. com/libertine/ job/build- 0-fetch/ 847 /jenkins. canonical. com/libertine/ job/build- 2-binpkg/ arch=amd64, release= xenial+ overlay/ 839/console /jenkins. canonical. com/libertine/ job/build- 2-binpkg/ arch=amd64, release= zesty/839/ console /jenkins. canonical. com/libertine/ job/build- 2-binpkg/ arch=i386, release= xenial+ overlay/ 839/console /jenkins. canonical. com/libertine/ job/build- 2-binpkg/ arch=i386, release= zesty/839/ console
https:/
Executed test runs:
FAILURE: https:/
SUCCESS: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
FAILURE: https:/
Click here to trigger a rebuild: /jenkins. canonical. com/libertine/ job/lp- libertine- ci/452/ rebuild
https:/