Merge lp:~fo0bar/turku/turku-agent-gonogo into lp:turku

Proposed by Ryan Finnie
Status: Superseded
Proposed branch: lp:~fo0bar/turku/turku-agent-gonogo
Merge into: lp:turku
Diff against target: 1081 lines (+983/-0) (has conflicts)
19 files modified
.bzrignore (+5/-0)
MANIFEST.in (+3/-0)
README (+4/-0)
setup.py (+39/-0)
turku-agent-ping (+20/-0)
turku-agent-ping.service (+6/-0)
turku-agent-ping.timer (+10/-0)
turku-agent-rsyncd-wrapper (+20/-0)
turku-agent-rsyncd.conf (+8/-0)
turku-agent-rsyncd.init-debian (+55/-0)
turku-agent-rsyncd.service (+10/-0)
turku-agent.cron (+3/-0)
turku-update-config (+20/-0)
turku-update-config.service (+6/-0)
turku-update-config.timer (+10/-0)
turku_agent/ping.py (+211/-0)
turku_agent/rsyncd_wrapper.py (+42/-0)
turku_agent/update_config.py (+179/-0)
turku_agent/utils.py (+332/-0)
Conflict adding file .bzrignore.  Moved existing file to .bzrignore.moved.
Conflict adding file setup.py.  Moved existing file to setup.py.moved.
To merge this branch: bzr merge lp:~fo0bar/turku/turku-agent-gonogo
Reviewer Review Type Date Requested Status
Canonical IS Reviewers Pending
Review via email: mp+368853@code.launchpad.net
To post a comment you must log in.
lp:~fo0bar/turku/turku-agent-gonogo updated
52. By Ryan Finnie

Allow gonogo_program to be stored in config, allow --gonogo-program to be shlex-split

Unmerged revisions

52. By Ryan Finnie

Allow gonogo_program to be stored in config, allow --gonogo-program to be shlex-split

51. By Colin Watson

On Upstart, check if turku-agent-rsyncd is already running before starting it.

Reviewed-on: https://code.launchpad.net/~cjwatson/turku/turku-agent-fix-rsyncd-interaction/+merge/367847
Reviewed-by: Haw Loeung <email address hidden>

50. By Colin Watson

Don't restart rsyncd when updating its configuration.

Reviewed-on: https://code.launchpad.net/~cjwatson/turku/turku-agent-fix-rsyncd-interaction/+merge/367418
Reviewed-by: Haw Loeung <email address hidden>

49. By Ryan Finnie

Fix Python 3 .values conversion with --restore

48. By Ryan Finnie

Remove early legacy config migrations/mitigations

47. By Ryan Finnie

Convert to Python 3

Program functionality is identical to previous, but will now need
to be installed with python3.

46. By Ryan Finnie

turku-agent-ping: Add --gonogo-program

45. By Ryan Finnie

Fix incorrect description turku-update-config.timer

44. By Ryan Finnie

Add systemd timers for turku-agent-ping and turku-update-config

43. By Ryan Finnie

[jacekn] Make sure update_config errors are not ignored. This allows for hooks to fail on bad configuration.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file '.bzrignore'
2--- .bzrignore 1970-01-01 00:00:00 +0000
3+++ .bzrignore 2019-06-15 00:58:25 +0000
4@@ -0,0 +1,5 @@
5+*.pyc
6+./build/
7+./dist/
8+./MANIFEST
9+./*.egg-info/
10
11=== renamed file '.bzrignore' => '.bzrignore.moved'
12=== added file 'MANIFEST.in'
13--- MANIFEST.in 1970-01-01 00:00:00 +0000
14+++ MANIFEST.in 2019-06-15 00:58:25 +0000
15@@ -0,0 +1,3 @@
16+include turku-agent.cron
17+include turku-agent-rsyncd.conf
18+include turku-agent-rsyncd.service
19
20=== added file 'README'
21--- README 1970-01-01 00:00:00 +0000
22+++ README 2019-06-15 00:58:25 +0000
23@@ -0,0 +1,4 @@
24+Turku backups - client agent
25+Copyright 2015 Canonical Ltd.
26+
27+https://launchpad.net/turku
28
29=== added file 'setup.py'
30--- setup.py 1970-01-01 00:00:00 +0000
31+++ setup.py 2019-06-15 00:58:25 +0000
32@@ -0,0 +1,39 @@
33+#!/usr/bin/env python3
34+
35+# Turku backups - client agent
36+# Copyright 2015 Canonical Ltd.
37+#
38+# This program is free software: you can redistribute it and/or modify it
39+# under the terms of the GNU General Public License version 3, as published by
40+# the Free Software Foundation.
41+#
42+# This program is distributed in the hope that it will be useful, but WITHOUT
43+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
44+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
45+# General Public License for more details.
46+#
47+# You should have received a copy of the GNU General Public License along with
48+# this program. If not, see <http://www.gnu.org/licenses/>.
49+
50+import sys
51+from setuptools import setup
52+
53+assert(sys.version_info > (3, 4))
54+
55+
56+setup(
57+ name='turku_agent',
58+ description='Turku backups - client agent',
59+ version='0.2.0',
60+ author='Ryan Finnie',
61+ author_email='ryan.finnie@canonical.com',
62+ url='https://launchpad.net/turku',
63+ packages=['turku_agent'],
64+ entry_points={
65+ 'console_scripts': [
66+ 'turku-agent-ping = turku_agent.ping:main',
67+ 'turku-agent-rsyncd-wrapper = turku_agent.rsyncd_wrapper:main',
68+ 'turku-update-config = turku_agent.update_config:main',
69+ ],
70+ },
71+)
72
73=== renamed file 'setup.py' => 'setup.py.moved'
74=== added file 'turku-agent-ping'
75--- turku-agent-ping 1970-01-01 00:00:00 +0000
76+++ turku-agent-ping 2019-06-15 00:58:25 +0000
77@@ -0,0 +1,20 @@
78+#!/usr/bin/env python3
79+
80+# Turku backups - client agent
81+# Copyright 2015 Canonical Ltd.
82+#
83+# This program is free software: you can redistribute it and/or modify it
84+# under the terms of the GNU General Public License version 3, as published by
85+# the Free Software Foundation.
86+#
87+# This program is distributed in the hope that it will be useful, but WITHOUT
88+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
89+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
90+# General Public License for more details.
91+#
92+# You should have received a copy of the GNU General Public License along with
93+# this program. If not, see <http://www.gnu.org/licenses/>.
94+
95+import sys
96+from turku_agent import ping
97+sys.exit(ping.main())
98
99=== added file 'turku-agent-ping.service'
100--- turku-agent-ping.service 1970-01-01 00:00:00 +0000
101+++ turku-agent-ping.service 2019-06-15 00:58:25 +0000
102@@ -0,0 +1,6 @@
103+[Unit]
104+Description=turku-agent-ping
105+
106+[Service]
107+Type=oneshot
108+ExecStart=/usr/bin/env turku-agent-ping
109
110=== added file 'turku-agent-ping.timer'
111--- turku-agent-ping.timer 1970-01-01 00:00:00 +0000
112+++ turku-agent-ping.timer 2019-06-15 00:58:25 +0000
113@@ -0,0 +1,10 @@
114+[Unit]
115+Description=turku-agent-ping
116+
117+[Timer]
118+OnCalendar=*:0/5
119+RandomizedDelaySec=5m
120+Persistent=true
121+
122+[Install]
123+WantedBy=timers.target
124
125=== added file 'turku-agent-rsyncd-wrapper'
126--- turku-agent-rsyncd-wrapper 1970-01-01 00:00:00 +0000
127+++ turku-agent-rsyncd-wrapper 2019-06-15 00:58:25 +0000
128@@ -0,0 +1,20 @@
129+#!/usr/bin/env python3
130+
131+# Turku backups - client agent
132+# Copyright 2015 Canonical Ltd.
133+#
134+# This program is free software: you can redistribute it and/or modify it
135+# under the terms of the GNU General Public License version 3, as published by
136+# the Free Software Foundation.
137+#
138+# This program is distributed in the hope that it will be useful, but WITHOUT
139+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
140+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
141+# General Public License for more details.
142+#
143+# You should have received a copy of the GNU General Public License along with
144+# this program. If not, see <http://www.gnu.org/licenses/>.
145+
146+import sys
147+from turku_agent import rsyncd_wrapper
148+sys.exit(rsyncd_wrapper.main())
149
150=== added file 'turku-agent-rsyncd.conf'
151--- turku-agent-rsyncd.conf 1970-01-01 00:00:00 +0000
152+++ turku-agent-rsyncd.conf 2019-06-15 00:58:25 +0000
153@@ -0,0 +1,8 @@
154+description "turku rsync daemon"
155+
156+start on runlevel [2345]
157+stop on runlevel [!2345]
158+
159+respawn
160+
161+exec /usr/bin/env turku-agent-rsyncd-wrapper
162
163=== added file 'turku-agent-rsyncd.init-debian'
164--- turku-agent-rsyncd.init-debian 1970-01-01 00:00:00 +0000
165+++ turku-agent-rsyncd.init-debian 2019-06-15 00:58:25 +0000
166@@ -0,0 +1,55 @@
167+#! /bin/sh
168+
169+### BEGIN INIT INFO
170+# Provides: turku-agent-rsyncd
171+# Required-Start: $remote_fs $syslog
172+# Required-Stop: $remote_fs $syslog
173+# Should-Start: $named
174+# Default-Start: 2 3 4 5
175+# Default-Stop:
176+# Short-Description: turku rsync daemon
177+# Description: turku rsync daemon
178+### END INIT INFO
179+
180+set -e
181+
182+PID_FILE=/var/run/turku-agent-rsyncd.pid
183+
184+. /lib/lsb/init-functions
185+
186+export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
187+
188+case "$1" in
189+ start)
190+ log_daemon_msg "Starting turku rsync daemon" "turku-agent-rsyncd"
191+ start-stop-daemon --start --quiet --background --make-pidfile \
192+ --pidfile $PID_FILE --startas /usr/bin/env -- turku-agent-rsyncd-wrapper
193+ log_end_msg $?
194+ ;;
195+ stop)
196+ log_daemon_msg "Stopping turku rsync daemon" "turku-agent-rsyncd"
197+ start-stop-daemon --stop --quiet --oknodo --pidfile $PID_FILE --retry 5
198+ log_end_msg $?
199+ rm -f $PID_FILE
200+ ;;
201+
202+ reload|force-reload)
203+ log_daemon_msg "Reloading turku rsync daemon" "turku-agent-rsyncd"
204+ log_end_msg 0
205+ ;;
206+
207+ restart)
208+ "$0" stop
209+ "$0" start
210+ ;;
211+
212+ status)
213+ status_of_proc -p $PID_FILE rsync turku-agent-rsyncd
214+ exit $? # notreached due to set -e
215+ ;;
216+ *)
217+ echo "Usage: /etc/init.d/turku-agent-rsyncd {start|stop|reload|force-reload|restart|status}"
218+ exit 1
219+esac
220+
221+exit 0
222
223=== added file 'turku-agent-rsyncd.service'
224--- turku-agent-rsyncd.service 1970-01-01 00:00:00 +0000
225+++ turku-agent-rsyncd.service 2019-06-15 00:58:25 +0000
226@@ -0,0 +1,10 @@
227+[Unit]
228+Description=turku rsyncd daemon
229+ConditionPathExists=/var/lib/turku-agent/rsyncd.conf
230+
231+[Service]
232+ExecStart=/usr/bin/env turku-agent-rsyncd-wrapper
233+Restart=always
234+
235+[Install]
236+WantedBy=multi-user.target
237
238=== added file 'turku-agent.cron'
239--- turku-agent.cron 1970-01-01 00:00:00 +0000
240+++ turku-agent.cron 2019-06-15 00:58:25 +0000
241@@ -0,0 +1,3 @@
242+PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
243+*/5 * * * * root sh -c 'systemctl is-active basic.target 2>/dev/null >/dev/null || turku-agent-ping --wait=300 >/dev/null 2>/dev/null'
244+0 0,12 * * * root sh -c 'systemctl is-active basic.target 2>/dev/null >/dev/null || turku-update-config --wait=7200 >/dev/null 2>/dev/null'
245
246=== added file 'turku-update-config'
247--- turku-update-config 1970-01-01 00:00:00 +0000
248+++ turku-update-config 2019-06-15 00:58:25 +0000
249@@ -0,0 +1,20 @@
250+#!/usr/bin/env python3
251+
252+# Turku backups - client agent
253+# Copyright 2015 Canonical Ltd.
254+#
255+# This program is free software: you can redistribute it and/or modify it
256+# under the terms of the GNU General Public License version 3, as published by
257+# the Free Software Foundation.
258+#
259+# This program is distributed in the hope that it will be useful, but WITHOUT
260+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
261+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
262+# General Public License for more details.
263+#
264+# You should have received a copy of the GNU General Public License along with
265+# this program. If not, see <http://www.gnu.org/licenses/>.
266+
267+import sys
268+from turku_agent import update_config
269+sys.exit(update_config.main())
270
271=== added file 'turku-update-config.service'
272--- turku-update-config.service 1970-01-01 00:00:00 +0000
273+++ turku-update-config.service 2019-06-15 00:58:25 +0000
274@@ -0,0 +1,6 @@
275+[Unit]
276+Description=turku-update-config
277+
278+[Service]
279+Type=oneshot
280+ExecStart=/usr/bin/env turku-update-config
281
282=== added file 'turku-update-config.timer'
283--- turku-update-config.timer 1970-01-01 00:00:00 +0000
284+++ turku-update-config.timer 2019-06-15 00:58:25 +0000
285@@ -0,0 +1,10 @@
286+[Unit]
287+Description=turku-update-config
288+
289+[Timer]
290+OnCalendar=0,12:0
291+RandomizedDelaySec=12h
292+Persistent=true
293+
294+[Install]
295+WantedBy=timers.target
296
297=== added directory 'turku_agent'
298=== added file 'turku_agent/__init__.py'
299=== added file 'turku_agent/ping.py'
300--- turku_agent/ping.py 1970-01-01 00:00:00 +0000
301+++ turku_agent/ping.py 2019-06-15 00:58:25 +0000
302@@ -0,0 +1,211 @@
303+#!/usr/bin/env python3
304+
305+# Turku backups - client agent
306+# Copyright 2015 Canonical Ltd.
307+#
308+# This program is free software: you can redistribute it and/or modify it
309+# under the terms of the GNU General Public License version 3, as published by
310+# the Free Software Foundation.
311+#
312+# This program is distributed in the hope that it will be useful, but WITHOUT
313+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
314+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
315+# General Public License for more details.
316+#
317+# You should have received a copy of the GNU General Public License along with
318+# this program. If not, see <http://www.gnu.org/licenses/>.
319+
320+
321+import os
322+import json
323+import random
324+import shlex
325+import subprocess
326+import tempfile
327+import time
328+from .utils import load_config, acquire_lock, api_call
329+
330+
331+def parse_args():
332+ import argparse
333+
334+ parser = argparse.ArgumentParser(
335+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
336+ parser.add_argument('--config-dir', '-c', type=str, default='/etc/turku-agent')
337+ parser.add_argument('--wait', '-w', type=float)
338+ parser.add_argument('--restore', action='store_true')
339+ parser.add_argument('--restore-storage', type=str, default=None)
340+ parser.add_argument('--gonogo-program', type=str, default=None)
341+ return parser.parse_args()
342+
343+
344+def call_ssh(config, storage, ssh_req):
345+ # Write the server host public key
346+ t = tempfile.NamedTemporaryFile()
347+ for key in storage['ssh_ping_host_keys']:
348+ t.write(('%s %s\n' % (storage['ssh_ping_host'], key)).encode('UTF-8'))
349+ t.flush()
350+
351+ # Call ssh
352+ ssh_command = config['ssh_command']
353+ ssh_command += [
354+ '-T',
355+ '-o', 'BatchMode=yes',
356+ '-o', 'UserKnownHostsFile=%s' % t.name,
357+ '-o', 'StrictHostKeyChecking=yes',
358+ '-o', 'CheckHostIP=no',
359+ '-i', config['ssh_private_key_file'],
360+ '-R', '%d:%s:%d' % (ssh_req['port'], config['rsyncd_local_address'], config['rsyncd_local_port']),
361+ '-p', str(storage['ssh_ping_port']),
362+ '-l', storage['ssh_ping_user'],
363+ storage['ssh_ping_host'],
364+ 'turku-ping-remote',
365+ ]
366+ p = subprocess.Popen(ssh_command, stdin=subprocess.PIPE)
367+
368+ # Write the ssh request
369+ p.stdin.write((json.dumps(ssh_req) + '\n.\n').encode('UTF-8'))
370+ p.stdin.flush()
371+
372+ # Wait for the server to close the SSH connection
373+ try:
374+ p.wait()
375+ except KeyboardInterrupt:
376+ pass
377+
378+ # Cleanup
379+ t.close()
380+
381+
382+def main():
383+ args = parse_args()
384+
385+ # Sleep a random amount of time if requested
386+ if args.wait:
387+ time.sleep(random.uniform(0, args.wait))
388+
389+ config = load_config(args.config_dir)
390+
391+ # Basic checks
392+ for i in ('ssh_private_key_file', 'machine_uuid', 'machine_secret', 'api_url'):
393+ if i not in config:
394+ return
395+ if not os.path.isfile(config['ssh_private_key_file']):
396+ return
397+
398+ # If a go/no-go program is defined, run it and only go if it exits 0.
399+ if args.gonogo_program:
400+ gonogo_program = shlex.split(args.gonogo_program)
401+ elif config['gonogo_program']:
402+ gonogo_program = config['gonogo_program']
403+ else:
404+ gonogo_program = []
405+ if gonogo_program:
406+ try:
407+ subprocess.check_call(gonogo_program)
408+ except (subprocess.CalledProcessError, OSError):
409+ return
410+
411+ lock = acquire_lock(os.path.join(config['lock_dir'], 'turku-agent-ping.lock'))
412+
413+ restore_mode = args.restore
414+
415+ # Check with the API server
416+ api_out = {}
417+
418+ machine_merge_map = (
419+ ('machine_uuid', 'uuid'),
420+ ('machine_secret', 'secret'),
421+ )
422+ api_out['machine'] = {}
423+ for a, b in machine_merge_map:
424+ if a in config:
425+ api_out['machine'][b] = config[a]
426+
427+ if restore_mode:
428+ print('Entering restore mode.')
429+ print()
430+ api_reply = api_call(config['api_url'], 'agent_ping_restore', api_out)
431+
432+ sources_by_storage = {}
433+ for source_name in api_reply['machine']['sources']:
434+ source = api_reply['machine']['sources'][source_name]
435+ if source_name not in config['sources']:
436+ continue
437+ if 'storage' not in source:
438+ continue
439+ if source['storage']['name'] not in sources_by_storage:
440+ sources_by_storage[source['storage']['name']] = {}
441+ sources_by_storage[source['storage']['name']][source_name] = source
442+
443+ if len(sources_by_storage) == 0:
444+ print('Cannot find any appropraite sources.')
445+ return
446+ print('This machine\'s sources are on the following storage units:')
447+ for storage_name in sources_by_storage:
448+ print(' %s' % storage_name)
449+ for source_name in sources_by_storage[storage_name]:
450+ print(' %s' % source_name)
451+ print()
452+ if len(sources_by_storage) == 1:
453+ storage = list(list(sources_by_storage.values())[0].values())[0]['storage']
454+ elif args.restore_storage:
455+ if args.restore_storage in sources_by_storage:
456+ storage = sources_by_storage[args.restore_storage]['storage']
457+ else:
458+ print('Cannot find appropriate storage "%s"' % args.restore_storage)
459+ return
460+ else:
461+ print('Multiple storages found. Please use --restore-storage to specify one.')
462+ return
463+
464+ ssh_req = {
465+ 'verbose': True,
466+ 'action': 'restore',
467+ 'port': random.randint(49152, 65535),
468+ }
469+ print('Storage unit: %s' % storage['name'])
470+ if 'restore_path' in config:
471+ print('Local destination path: %s' % config['restore_path'])
472+ print('Sample restore usage from storage unit:')
473+ print(
474+ ' RSYNC_PASSWORD=%s rsync -avzP --numeric-ids ${P?}/ rsync://%s@127.0.0.1:%s/%s/' % (
475+ config['restore_password'],
476+ config['restore_username'],
477+ ssh_req['port'], config['restore_module']
478+ )
479+ )
480+ print()
481+ call_ssh(config, storage, ssh_req)
482+ else:
483+ api_reply = api_call(config['api_url'], 'agent_ping_checkin', api_out)
484+
485+ if 'scheduled_sources' not in api_reply:
486+ return
487+ sources_by_storage = {}
488+ for source_name in api_reply['machine']['scheduled_sources']:
489+ source = api_reply['machine']['scheduled_sources'][source_name]
490+ if source_name not in config['sources']:
491+ continue
492+ if 'storage' not in source:
493+ continue
494+ if source['storage']['name'] not in sources_by_storage:
495+ sources_by_storage[source['storage']['name']] = {}
496+ sources_by_storage[source['storage']['name']][source_name] = source
497+
498+ for storage_name in sources_by_storage:
499+ ssh_req = {
500+ 'verbose': True,
501+ 'action': 'checkin',
502+ 'port': random.randint(49152, 65535),
503+ 'sources': {},
504+ }
505+ for source in sources_by_storage[storage_name]:
506+ ssh_req['sources'][source] = {
507+ 'username': config['sources'][source]['username'],
508+ 'password': config['sources'][source]['password'],
509+ }
510+ call_ssh(config, list(sources_by_storage[storage_name].values())[0]['storage'], ssh_req)
511+
512+ # Cleanup
513+ lock.close()
514
515=== added file 'turku_agent/rsyncd_wrapper.py'
516--- turku_agent/rsyncd_wrapper.py 1970-01-01 00:00:00 +0000
517+++ turku_agent/rsyncd_wrapper.py 2019-06-15 00:58:25 +0000
518@@ -0,0 +1,42 @@
519+#!/usr/bin/env python3
520+
521+# Turku backups - client agent
522+# Copyright 2015 Canonical Ltd.
523+#
524+# This program is free software: you can redistribute it and/or modify it
525+# under the terms of the GNU General Public License version 3, as published by
526+# the Free Software Foundation.
527+#
528+# This program is distributed in the hope that it will be useful, but WITHOUT
529+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
530+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
531+# General Public License for more details.
532+#
533+# You should have received a copy of the GNU General Public License along with
534+# this program. If not, see <http://www.gnu.org/licenses/>.
535+
536+import os
537+from .utils import load_config
538+
539+
540+def parse_args():
541+ import argparse
542+
543+ parser = argparse.ArgumentParser(
544+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
545+ parser.add_argument('--config-dir', '-c', type=str, default='/etc/turku-agent')
546+ parser.add_argument('--detach', action='store_true')
547+ return parser.parse_known_args()
548+
549+
550+def main():
551+ args, rest = parse_args()
552+
553+ config = load_config(args.config_dir)
554+ rsyncd_command = config['rsyncd_command']
555+ if not args.detach:
556+ rsyncd_command.append('--no-detach')
557+ rsyncd_command.append('--daemon')
558+ rsyncd_command.append('--config=%s' % os.path.join(config['var_dir'], 'rsyncd.conf'))
559+ rsyncd_command += rest
560+ os.execvp(rsyncd_command[0], rsyncd_command)
561
562=== added file 'turku_agent/update_config.py'
563--- turku_agent/update_config.py 1970-01-01 00:00:00 +0000
564+++ turku_agent/update_config.py 2019-06-15 00:58:25 +0000
565@@ -0,0 +1,179 @@
566+#!/usr/bin/env python3
567+
568+# Turku backups - client agent
569+# Copyright 2015 Canonical Ltd.
570+#
571+# This program is free software: you can redistribute it and/or modify it
572+# under the terms of the GNU General Public License version 3, as published by
573+# the Free Software Foundation.
574+#
575+# This program is distributed in the hope that it will be useful, but WITHOUT
576+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
577+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
578+# General Public License for more details.
579+#
580+# You should have received a copy of the GNU General Public License along with
581+# this program. If not, see <http://www.gnu.org/licenses/>.
582+
583+import random
584+import os
585+import subprocess
586+import time
587+import logging
588+from .utils import json_dump_p, json_dumps_p, load_config, fill_config, acquire_lock, api_call
589+
590+
591+class IncompleteConfigError(Exception):
592+ pass
593+
594+
595+def parse_args():
596+ import argparse
597+
598+ parser = argparse.ArgumentParser(
599+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
600+ parser.add_argument('--config-dir', '-c', type=str, default='/etc/turku-agent')
601+ parser.add_argument('--wait', '-w', type=float)
602+ parser.add_argument('--debug', action='store_true')
603+ return parser.parse_args()
604+
605+
606+def write_conf_files(config):
607+ # Build rsyncd.conf
608+ built_rsyncd_conf = (
609+ 'address = %s\n' % config['rsyncd_local_address'] +
610+ 'port = %d\n' % config['rsyncd_local_port'] +
611+ 'log file = /dev/stdout\n' +
612+ 'uid = root\n' +
613+ 'gid = root\n' +
614+ 'list = false\n\n'
615+ )
616+ rsyncd_secrets = []
617+ rsyncd_secrets.append((config['restore_username'], config['restore_password']))
618+ built_rsyncd_conf += (
619+ '[%s]\n' +
620+ ' path = %s\n' +
621+ ' auth users = %s\n' +
622+ ' secrets file = %s\n' +
623+ ' read only = false\n\n'
624+ ) % (
625+ config['restore_module'],
626+ config['restore_path'],
627+ config['restore_username'],
628+ os.path.join(config['var_dir'], 'rsyncd.secrets'),
629+ )
630+ for s in config['sources']:
631+ sd = config['sources'][s]
632+ rsyncd_secrets.append((sd['username'], sd['password']))
633+ built_rsyncd_conf += (
634+ '[%s]\n' +
635+ ' path = %s\n' +
636+ ' auth users = %s\n' +
637+ ' secrets file = %s\n' +
638+ ' read only = true\n\n'
639+ ) % (
640+ s,
641+ sd['path'],
642+ sd['username'],
643+ os.path.join(config['var_dir'], 'rsyncd.secrets'),
644+ )
645+ with open(os.path.join(config['var_dir'], 'rsyncd.conf'), 'w') as f:
646+ f.write(built_rsyncd_conf)
647+
648+ # Build rsyncd.secrets
649+ built_rsyncd_secrets = ''
650+ for (username, password) in rsyncd_secrets:
651+ built_rsyncd_secrets += username + ':' + password + '\n'
652+ with open(os.path.join(config['var_dir'], 'rsyncd.secrets'), 'w') as f:
653+ os.fchmod(f.fileno(), 0o600)
654+ f.write(built_rsyncd_secrets)
655+
656+
657+def init_is_upstart():
658+ try:
659+ return 'upstart' in subprocess.check_output(
660+ ['initctl', 'version'],
661+ stderr=subprocess.DEVNULL, universal_newlines=True)
662+ except (FileNotFoundError, subprocess.CalledProcessError):
663+ return False
664+
665+
666+def start_services():
667+ # Start rsyncd if it isn't already running.
668+ # Note that we do *not* need to reload rsyncd when changing rsyncd.conf,
669+ # as it rereads it on every client connection; but we may need to start
670+ # it as it won't start if its configuration file doesn't exist.
671+ if init_is_upstart():
672+ # With Upstart, start will fail if the service is already running,
673+ # so we need to check for that first.
674+ try:
675+ if 'start/running' in subprocess.check_output(
676+ ['status', 'turku-agent-rsyncd'],
677+ stderr=subprocess.STDOUT, universal_newlines=True):
678+ return
679+ except subprocess.CalledProcessError:
680+ pass
681+ subprocess.check_call(['service', 'turku-agent-rsyncd', 'start'])
682+
683+
684+def send_config(config):
685+ required_keys = ['api_url']
686+ if 'api_auth' not in config:
687+ required_keys += ['api_auth_name', 'api_auth_secret']
688+ for k in required_keys:
689+ if k not in config:
690+ raise IncompleteConfigError('Required config "%s" not found.' % k)
691+
692+ api_out = {}
693+ if ('api_auth_name' in config) and ('api_auth_secret' in config):
694+ # name/secret style
695+ api_out['auth'] = {
696+ 'name': config['api_auth_name'],
697+ 'secret': config['api_auth_secret'],
698+ }
699+ else:
700+ # nameless secret style
701+ api_out['auth'] = config['api_auth']
702+
703+ # Merge the following options into the machine section
704+ machine_merge_map = (
705+ ('machine_uuid', 'uuid'),
706+ ('machine_secret', 'secret'),
707+ ('environment_name', 'environment_name'),
708+ ('service_name', 'service_name'),
709+ ('unit_name', 'unit_name'),
710+ ('ssh_public_key', 'ssh_public_key'),
711+ ('published', 'published'),
712+ )
713+ api_out['machine'] = {}
714+ for a, b in machine_merge_map:
715+ if a in config:
716+ api_out['machine'][b] = config[a]
717+
718+ api_out['machine']['sources'] = config['sources']
719+
720+ api_reply = api_call(config['api_url'], 'update_config', api_out)
721+
722+
723+def main():
724+ args = parse_args()
725+ # Sleep a random amount of time if requested
726+ if args.wait:
727+ time.sleep(random.uniform(0, args.wait))
728+
729+ config = load_config(args.config_dir)
730+ lock = acquire_lock(os.path.join(config['lock_dir'], 'turku-update-config.lock'))
731+ fill_config(config)
732+ if args.debug:
733+ print(json_dumps_p(config))
734+ write_conf_files(config)
735+ try:
736+ send_config(config)
737+ except Exception as e:
738+ if args.debug:
739+ raise
740+ logging.exception(e)
741+ return 1
742+ start_services()
743+
744+ lock.close()
745
746=== added file 'turku_agent/utils.py'
747--- turku_agent/utils.py 1970-01-01 00:00:00 +0000
748+++ turku_agent/utils.py 2019-06-15 00:58:25 +0000
749@@ -0,0 +1,332 @@
750+#!/usr/bin/env python3
751+
752+# Turku backups - client agent
753+# Copyright 2015 Canonical Ltd.
754+#
755+# This program is free software: you can redistribute it and/or modify it
756+# under the terms of the GNU General Public License version 3, as published by
757+# the Free Software Foundation.
758+#
759+# This program is distributed in the hope that it will be useful, but WITHOUT
760+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
761+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
762+# General Public License for more details.
763+#
764+# You should have received a copy of the GNU General Public License along with
765+# this program. If not, see <http://www.gnu.org/licenses/>.
766+
767+import uuid
768+import string
769+import random
770+import json
771+import os
772+import copy
773+import subprocess
774+import platform
775+import urllib.parse
776+import http.client
777+
778+
779+class RuntimeLock():
780+ name = None
781+ file = None
782+
783+ def __init__(self, name):
784+ import fcntl
785+ file = open(name, 'w')
786+ try:
787+ fcntl.lockf(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
788+ except IOError as e:
789+ import errno
790+ if e.errno in (errno.EACCES, errno.EAGAIN):
791+ raise
792+ file.write('%10s\n' % os.getpid())
793+ file.flush()
794+ file.seek(0)
795+ self.name = name
796+ self.file = file
797+
798+ def close(self):
799+ if self.file:
800+ self.file.close()
801+ self.file = None
802+ os.unlink(self.name)
803+
804+ def __del__(self):
805+ self.close()
806+
807+ def __enter__(self):
808+ self.file.__enter__()
809+ return self
810+
811+ def __exit__(self, exc, value, tb):
812+ result = self.file.__exit__(exc, value, tb)
813+ self.close()
814+ return result
815+
816+
817+def acquire_lock(name):
818+ return RuntimeLock(name)
819+
820+
821+def json_dump_p(obj, f):
822+ """Calls json.dump with standard (pretty) formatting"""
823+ return json.dump(obj, f, sort_keys=True, indent=4, separators=(',', ': '))
824+
825+
826+def json_dumps_p(obj):
827+ """Calls json.dumps with standard (pretty) formatting"""
828+ return json.dumps(obj, sort_keys=True, indent=4, separators=(',', ': '))
829+
830+
831+def json_load_file(file):
832+ with open(file) as f:
833+ try:
834+ return json.load(f)
835+ except ValueError as e:
836+ e.args += (file,)
837+ raise
838+
839+
840+def dict_merge(s, m):
841+ """Recursively merge one dict into another."""
842+ if not isinstance(m, dict):
843+ return m
844+ out = copy.deepcopy(s)
845+ for k, v in list(m.items()):
846+ if k in out and isinstance(out[k], dict):
847+ out[k] = dict_merge(out[k], v)
848+ else:
849+ out[k] = copy.deepcopy(v)
850+ return out
851+
852+
853+def load_config(config_dir):
854+ config = {}
855+ config['config_dir'] = config_dir
856+
857+ config_d = os.path.join(config['config_dir'], 'config.d')
858+ sources_d = os.path.join(config['config_dir'], 'sources.d')
859+
860+ # Merge in config.d/*.json to the root level
861+ config_files = []
862+ if os.path.isdir(config_d):
863+ config_files = [
864+ os.path.join(config_d, fn)
865+ for fn in os.listdir(config_d)
866+ if fn.endswith('.json')
867+ and os.path.isfile(os.path.join(config_d, fn))
868+ and os.access(os.path.join(config_d, fn), os.R_OK)
869+ ]
870+ config_files.sort()
871+ for file in config_files:
872+ config = dict_merge(config, json_load_file(file))
873+
874+ if 'var_dir' not in config:
875+ config['var_dir'] = '/var/lib/turku-agent'
876+
877+ var_config_d = os.path.join(config['var_dir'], 'config.d')
878+
879+ # Load /var config.d files
880+ var_config = {}
881+ var_config_files = []
882+ if os.path.isdir(var_config_d):
883+ var_config_files = [
884+ os.path.join(var_config_d, fn)
885+ for fn in os.listdir(var_config_d)
886+ if fn.endswith('.json')
887+ and os.path.isfile(os.path.join(var_config_d, fn))
888+ and os.access(os.path.join(var_config_d, fn), os.R_OK)
889+ ]
890+ var_config_files.sort()
891+ for file in var_config_files:
892+ var_config = dict_merge(var_config, json_load_file(file))
893+ # /etc gets priority over /var
894+ var_config = dict_merge(var_config, config)
895+ config = var_config
896+
897+ if 'lock_dir' not in config:
898+ config['lock_dir'] = '/var/lock'
899+
900+ if 'rsyncd_command' not in config:
901+ config['rsyncd_command'] = ['rsync']
902+
903+ if 'rsyncd_local_address' not in config:
904+ config['rsyncd_local_address'] = '127.0.0.1'
905+
906+ if 'rsyncd_local_port' not in config:
907+ config['rsyncd_local_port'] = 27873
908+
909+ if 'ssh_command' not in config:
910+ config['ssh_command'] = ['ssh']
911+
912+ if 'gonogo_program' not in config:
913+ config['gonogo_program'] = []
914+
915+ var_sources_d = os.path.join(config['var_dir'], 'sources.d')
916+
917+ # Validate the unit name
918+ if 'unit_name' not in config:
919+ config['unit_name'] = platform.node()
920+ # If this isn't in the on-disk config, don't write it; just
921+ # generate it every time
922+
923+ # Pull the SSH public key
924+ if os.path.isfile(os.path.join(config['var_dir'], 'ssh_key.pub')):
925+ with open(os.path.join(config['var_dir'], 'ssh_key.pub')) as f:
926+ config['ssh_public_key'] = f.read().rstrip()
927+ config['ssh_public_key_file'] = os.path.join(config['var_dir'], 'ssh_key.pub')
928+ config['ssh_private_key_file'] = os.path.join(config['var_dir'], 'ssh_key')
929+
930+ sources_config = {}
931+ # Merge in sources.d/*.json to the sources dict
932+ sources_files = []
933+ if os.path.isdir(sources_d):
934+ sources_files = [
935+ os.path.join(sources_d, fn)
936+ for fn in os.listdir(sources_d)
937+ if fn.endswith('.json')
938+ and os.path.isfile(os.path.join(sources_d, fn))
939+ and os.access(os.path.join(sources_d, fn), os.R_OK)
940+ ]
941+ sources_files.sort()
942+ var_sources_files = []
943+ if os.path.isdir(var_sources_d):
944+ var_sources_files = [
945+ os.path.join(var_sources_d, fn)
946+ for fn in os.listdir(var_sources_d)
947+ if fn.endswith('.json')
948+ and os.path.isfile(os.path.join(var_sources_d, fn))
949+ and os.access(os.path.join(var_sources_d, fn), os.R_OK)
950+ ]
951+ var_sources_files.sort()
952+ sources_files += var_sources_files
953+ for file in sources_files:
954+ sources_config = dict_merge(sources_config, json_load_file(file))
955+
956+ # Check for required sources options
957+ for s in list(sources_config.keys()):
958+ if 'path' not in sources_config[s]:
959+ del sources_config[s]
960+
961+ config['sources'] = sources_config
962+
963+ return config
964+
965+
966+def fill_config(config):
967+ config_d = os.path.join(config['config_dir'], 'config.d')
968+ sources_d = os.path.join(config['config_dir'], 'sources.d')
969+ var_config_d = os.path.join(config['var_dir'], 'config.d')
970+ var_sources_d = os.path.join(config['var_dir'], 'sources.d')
971+
972+ # Create required directories
973+ for d in (config_d, sources_d, var_config_d, var_sources_d):
974+ if not os.path.isdir(d):
975+ os.makedirs(d)
976+
977+ # Validate the machine UUID/secret
978+ write_uuid_data = False
979+ if 'machine_uuid' not in config:
980+ config['machine_uuid'] = str(uuid.uuid4())
981+ write_uuid_data = True
982+ if 'machine_secret' not in config:
983+ config['machine_secret'] = ''.join(
984+ random.choice(string.ascii_letters + string.digits)
985+ for i in range(30)
986+ )
987+ write_uuid_data = True
988+ # Write out the machine UUID/secret if needed
989+ if write_uuid_data:
990+ with open(os.path.join(var_config_d, '10-machine_uuid.json'), 'w') as f:
991+ os.fchmod(f.fileno(), 0o600)
992+ json_dump_p({
993+ 'machine_uuid': config['machine_uuid'],
994+ 'machine_secret': config['machine_secret'],
995+ }, f)
996+
997+ # Restoration configuration
998+ write_restore_data = False
999+ if 'restore_path' not in config:
1000+ config['restore_path'] = '/var/backups/turku-agent/restore'
1001+ write_restore_data = True
1002+ if 'restore_module' not in config:
1003+ config['restore_module'] = 'turku-restore'
1004+ write_restore_data = True
1005+ if 'restore_username' not in config:
1006+ config['restore_username'] = str(uuid.uuid4())
1007+ write_restore_data = True
1008+ if 'restore_password' not in config:
1009+ config['restore_password'] = ''.join(
1010+ random.choice(string.ascii_letters + string.digits)
1011+ for i in range(30)
1012+ )
1013+ write_restore_data = True
1014+ if write_restore_data:
1015+ with open(os.path.join(var_config_d, '10-restore.json'), 'w') as f:
1016+ os.fchmod(f.fileno(), 0o600)
1017+ restore_out = {
1018+ 'restore_path': config['restore_path'],
1019+ 'restore_module': config['restore_module'],
1020+ 'restore_username': config['restore_username'],
1021+ 'restore_password': config['restore_password'],
1022+ }
1023+ json_dump_p(restore_out, f)
1024+ if not os.path.isdir(config['restore_path']):
1025+ os.makedirs(config['restore_path'])
1026+
1027+ # Generate the SSH keypair if it doesn't exist
1028+ if 'ssh_private_key_file' not in config:
1029+ subprocess.check_call([
1030+ 'ssh-keygen', '-t', 'rsa', '-N', '', '-C', 'turku',
1031+ '-f', os.path.join(config['var_dir'], 'ssh_key')
1032+ ])
1033+ with open(os.path.join(config['var_dir'], 'ssh_key.pub')) as f:
1034+ config['ssh_public_key'] = f.read().rstrip()
1035+ config['ssh_public_key_file'] = os.path.join(config['var_dir'], 'ssh_key.pub')
1036+ config['ssh_private_key_file'] = os.path.join(config['var_dir'], 'ssh_key')
1037+
1038+ for s in config['sources']:
1039+ # Check for missing usernames/passwords
1040+ if not ('username' in config['sources'][s] or 'password' in config['sources'][s]):
1041+ sources_secrets_d = os.path.join(config['config_dir'], 'sources_secrets.d')
1042+ if 'username' not in config['sources'][s]:
1043+ config['sources'][s]['username'] = str(uuid.uuid4())
1044+ if 'password' not in config['sources'][s]:
1045+ config['sources'][s]['password'] = ''.join(
1046+ random.choice(string.ascii_letters + string.digits)
1047+ for i in range(30)
1048+ )
1049+ with open(os.path.join(var_sources_d, '10-' + s + '.json'), 'w') as f:
1050+ os.fchmod(f.fileno(), 0o600)
1051+ json_dump_p({
1052+ s: {
1053+ 'username': config['sources'][s]['username'],
1054+ 'password': config['sources'][s]['password'],
1055+ }
1056+ }, f)
1057+
1058+
1059+def api_call(api_url, cmd, post_data, timeout=5):
1060+ url = urllib.parse.urlparse(api_url)
1061+ if url.scheme == 'https':
1062+ h = http.client.HTTPSConnection(url.netloc, timeout=timeout)
1063+ else:
1064+ h = http.client.HTTPConnection(url.netloc, timeout=timeout)
1065+ out = json.dumps(post_data)
1066+ h.putrequest('POST', '%s/%s' % (url.path, cmd))
1067+ h.putheader('Content-Type', 'application/json')
1068+ h.putheader('Content-Length', len(out))
1069+ h.putheader('Accept', 'application/json')
1070+ h.endheaders()
1071+ h.send(out.encode('UTF-8'))
1072+
1073+ res = h.getresponse()
1074+ if not res.status == http.client.OK:
1075+ raise Exception('Received error %d (%s) from API server' % (res.status, res.reason))
1076+ if not res.getheader('content-type') == 'application/json':
1077+ raise Exception('Received invalid reply from API server')
1078+ try:
1079+ return json.loads(res.read().decode('UTF-8'))
1080+ except ValueError:
1081+ raise Exception('Received invalid reply from API server')

Subscribers

People subscribed via source and target branches

to all changes: