1
# Copyright 2014-2015 Canonical Limited.
3
# This file is part of charm-helpers.
5
# charm-helpers is free software: you can redistribute it and/or modify
6
# it under the terms of the GNU Lesser General Public License version 3 as
7
# published by the Free Software Foundation.
9
# charm-helpers is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU Lesser General Public License for more details.
14
# You should have received a copy of the GNU Lesser General Public License
15
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
17
# Easy file synchronization among peer units using ssh + unison.
19
# For the -joined, -changed, and -departed peer relations, add a call to
20
# ssh_authorized_peers() describing the peer relation and the desired
21
# user + group. After all peer relations have settled, all hosts should
22
# be able to connect to on another via key auth'd ssh as the specified user.
24
# Other hooks are then free to synchronize files and directories using
27
# For a peer relation named 'cluster', for example:
29
# cluster-relation-joined:
31
# ssh_authorized_peers(peer_interface='cluster',
32
# user='juju_ssh', group='juju_ssh',
33
# ensure_local_user=True)
36
# cluster-relation-changed:
38
# ssh_authorized_peers(peer_interface='cluster',
39
# user='juju_ssh', group='juju_ssh',
40
# ensure_local_user=True)
43
# cluster-relation-departed:
45
# ssh_authorized_peers(peer_interface='cluster',
46
# user='juju_ssh', group='juju_ssh',
47
# ensure_local_user=True)
50
# Hooks are now free to sync files as easily as:
52
# files = ['/etc/fstab', '/etc/apt.conf.d/']
53
# sync_to_peers(peer_interface='cluster',
54
# user='juju_ssh, paths=[files])
56
# It is assumed the charm itself has setup permissions on each unit
57
# such that 'juju_ssh' has read + write permissions. Also assumed
58
# that the calling charm takes care of leader delegation.
60
# Additionally files can be synchronized only to an specific unit:
61
# sync_to_peer(slave_address, user='juju_ssh',
62
# paths=[files], verbose=False)
68
from subprocess import check_call, check_output
70
from charmhelpers.core.host import (
76
from charmhelpers.core.hookenv import (
88
BASE_CMD = ['unison', '-auto', '-batch=true', '-confirmbigdel=false',
89
'-fastcheck=true', '-group=false', '-owner=false',
90
'-prefer=newer', '-times=true']
93
def get_homedir(user):
95
user = pwd.getpwnam(user)
98
log('Could not get homedir for user %s: user exists?' % (user), ERROR)
102
def create_private_key(user, priv_key_path, key_type='rsa'):
107
if key_type not in types_bits:
108
log('Unknown ssh key type {}, using rsa'.format(key_type), ERROR)
110
if not os.path.isfile(priv_key_path):
111
log('Generating new SSH key for user %s.' % user)
112
cmd = ['ssh-keygen', '-q', '-N', '', '-t', key_type,
113
'-b', types_bits[key_type], '-f', priv_key_path]
116
log('SSH key already exists at %s.' % priv_key_path)
117
check_call(['chown', user, priv_key_path])
118
check_call(['chmod', '0600', priv_key_path])
121
def create_public_key(user, priv_key_path, pub_key_path):
122
if not os.path.isfile(pub_key_path):
123
log('Generating missing ssh public key @ %s.' % pub_key_path)
124
cmd = ['ssh-keygen', '-y', '-f', priv_key_path]
125
p = check_output(cmd).strip()
126
with open(pub_key_path, 'wb') as out:
128
check_call(['chown', user, pub_key_path])
131
def get_keypair(user):
132
home_dir = get_homedir(user)
133
ssh_dir = os.path.join(home_dir, '.ssh')
134
priv_key = os.path.join(ssh_dir, 'id_rsa')
135
pub_key = '%s.pub' % priv_key
137
if not os.path.isdir(ssh_dir):
139
check_call(['chown', '-R', user, ssh_dir])
141
create_private_key(user, priv_key)
142
create_public_key(user, priv_key, pub_key)
144
with open(priv_key, 'r') as p:
145
_priv = p.read().strip()
147
with open(pub_key, 'r') as p:
148
_pub = p.read().strip()
153
def write_authorized_keys(user, keys):
154
home_dir = get_homedir(user)
155
ssh_dir = os.path.join(home_dir, '.ssh')
156
auth_keys = os.path.join(ssh_dir, 'authorized_keys')
157
log('Syncing authorized_keys @ %s.' % auth_keys)
158
with open(auth_keys, 'w') as out:
160
out.write('%s\n' % k)
163
def write_known_hosts(user, hosts):
164
home_dir = get_homedir(user)
165
ssh_dir = os.path.join(home_dir, '.ssh')
166
known_hosts = os.path.join(ssh_dir, 'known_hosts')
169
cmd = ['ssh-keyscan', host]
170
remote_key = check_output(cmd, universal_newlines=True).strip()
171
khosts.append(remote_key)
172
log('Syncing known_hosts @ %s.' % known_hosts)
173
with open(known_hosts, 'w') as out:
175
out.write('%s\n' % host)
178
def ensure_user(user, group=None):
179
adduser(user, pwgen())
181
add_user_to_group(user, group)
184
def ssh_authorized_peers(peer_interface, user, group=None,
185
ensure_local_user=False):
187
Main setup function, should be called from both peer -changed and -joined
188
hooks with the same parameters.
190
if ensure_local_user:
191
ensure_user(user, group)
192
priv_key, pub_key = get_keypair(user)
194
if hook == '%s-relation-joined' % peer_interface:
195
relation_set(ssh_pub_key=pub_key)
196
elif hook == '%s-relation-changed' % peer_interface or \
197
hook == '%s-relation-departed' % peer_interface:
201
for r_id in relation_ids(peer_interface):
202
for unit in related_units(r_id):
203
ssh_pub_key = relation_get('ssh_pub_key',
206
priv_addr = relation_get('private-address',
210
keys.append(ssh_pub_key)
211
hosts.append(priv_addr)
213
log('ssh_authorized_peers(): ssh_pub_key '
214
'missing for unit %s, skipping.' % unit)
215
write_authorized_keys(user, keys)
216
write_known_hosts(user, hosts)
217
authed_hosts = ':'.join(hosts)
218
relation_set(ssh_authorized_hosts=authed_hosts)
221
def _run_as_user(user, gid=None):
223
user = pwd.getpwnam(user)
225
log('Invalid user: %s' % user)
228
gid = gid or user.pw_gid
229
os.environ['HOME'] = user.pw_dir
237
def run_as_user(user, cmd, gid=None):
238
return check_output(cmd, preexec_fn=_run_as_user(user, gid), cwd='/')
241
def collect_authed_hosts(peer_interface):
242
'''Iterate through the units on peer interface to find all that
243
have the calling host in its authorized hosts list'''
245
for r_id in (relation_ids(peer_interface) or []):
246
for unit in related_units(r_id):
247
private_addr = relation_get('private-address',
249
authed_hosts = relation_get('ssh_authorized_hosts',
253
log('Peer %s has not authorized *any* hosts yet, skipping.' %
257
if unit_private_ip() in authed_hosts.split(':'):
258
hosts.append(private_addr)
260
log('Peer %s has not authorized *this* host yet, skipping.' %
265
def sync_path_to_host(path, host, user, verbose=False, cmd=None, gid=None,
267
"""Sync path to an specific peer host
269
Propagates exception if operation fails and fatal=True.
271
cmd = cmd or copy(BASE_CMD)
273
cmd.append('-silent')
275
# removing trailing slash from directory paths, unison
276
# doesn't like these.
277
if path.endswith('/'):
278
path = path[:(len(path) - 1)]
280
cmd = cmd + [path, 'ssh://%s@%s/%s' % (user, host, path)]
283
log('Syncing local path %s to %s@%s:%s' % (path, user, host, path))
284
run_as_user(user, cmd, gid)
286
log('Error syncing remote files')
291
def sync_to_peer(host, user, paths=None, verbose=False, cmd=None, gid=None,
293
"""Sync paths to an specific peer host
295
Propagates exception if any operation fails and fatal=True.
299
sync_path_to_host(p, host, user, verbose, cmd, gid, fatal)
302
def sync_to_peers(peer_interface, user, paths=None, verbose=False, cmd=None,
303
gid=None, fatal=False):
304
"""Sync all hosts to an specific path
306
The type of group is integer, it allows user has permissions to
307
operate a directory have a different group id with the user id.
309
Propagates exception if any operation fails and fatal=True.
312
for host in collect_authed_hosts(peer_interface):
313
sync_to_peer(host, user, paths, verbose, cmd, gid, fatal)