source: dassldapsync/dassldapsync.py@ 1230

Last change on this file since 1230 was 1221, checked in by joergs, on Nov 16, 2016 at 4:18:48 PM

added syncrepl

  • Property svn:executable set to *
File size: 23.2 KB
RevLine 
[1218]1#!/usr/bin/python
2# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
3
[1221]4# core modules
[1219]5import argparse
6import ConfigParser
[1221]7import logging
8from pprint import pprint
9import signal
10import subprocess
11import sys
12import time
13
14# external modules
[1219]15import datetime
16import dateutil.parser
17import dateutil.tz
[1218]18import ldap
[1221]19from ldap.ldapobject import ReconnectLDAPObject
[1219]20import ldap.modlist
[1221]21from ldap.syncrepl import SyncreplConsumer
22import ldapurl
[1218]23import ldif
24
[1221]25
26
[1219]27def getArguments():
28 configfile = '/etc/dassldapsync.conf'
[1221]29 parser = argparse.ArgumentParser(description='Synchronize the content of two LDAP servers.')
[1219]30 parser.add_argument('-d', '--debug', action='store_true', help="enable debug output")
[1221]31 parser.add_argument('configfile', default=configfile,
32 help="Configuration file [default: {}]".format(configfile))
33 return parser.parse_args()
[1218]34
[1219]35
[1221]36class Options(object):
[1218]37 def __init__(self):
[1221]38 self.delete = True
39 self.starttls = False
40 self.updateonly = False
41 self.filter = None
42 self.attrlist = None
43 self.exclude = None
44 self.renameattr = None
45 self.renamecommand = None
46 self.pwd_max_days = 0
47
[1219]48class ConfigParserDefaults(ConfigParser.ConfigParser, object):
[1221]49 def get(self, section, option, default=None):
[1219]50 try:
51 result = super(self.__class__, self).get(section, option)
52 except ConfigParser.NoOptionError:
53 if default is None:
54 raise
55 else:
56 result = default
57 return result
[1218]58
[1221]59 def get_section(self, section):
60 if section in self._sections:
61 return self._sections[section]
62
63 def get0(self, section, option, default=None):
[1219]64 try:
65 result = super(self.__class__, self).get(section, option)
66 except ConfigParser.NoOptionError:
67 result = default
68 return result
[1218]69
[1221]70 def getboolean(self, section, option, default=None):
[1219]71 try:
72 result = super(self.__class__, self).getboolean(section, option)
73 except ConfigParser.NoOptionError:
74 if default is None:
75 raise
76 else:
77 result = default
78 return result
[1218]79
[1221]80 def get_ldap_url_obj(self, section):
81 baseurl = 'ldap://{server}:389/{basedn}'.format(**(self.get_section(section)))
82 attrs = None
83 if self.get0(section, 'attributes') is not None:
84 attrs = self.get(section, 'attributes').split(',')
85 return ldapurl.LDAPUrl(
86 baseurl,
87 dn=self.get(section, 'baseDn', ''),
88 who=self.get0(section, 'bindDn'),
89 cred=self.get0(section, 'basePassword'),
90 filterstr=self.get0(section, 'filter'),
91 attrs=attrs
92 )
93
94
[1218]95def readLDIFSource(path):
[1219]96 logger = logging.getLogger()
97 logger.info("reading LDAP objects from file {}".format(path))
[1221]98 with open(path, 'r') as f:
[1218]99 parser = ldif.LDIFRecordList(f)
100 parser.parse()
101 result = parser.all_records
102 return result
103
[1221]104def readLdapSource(server, binddn, bindpw, basedn, filterstr, attrlist=None, starttls=False):
[1219]105 logger = logging.getLogger()
106 logger.info("reading LDAP objects from server {}".format(server))
[1221]107 con = ldap.open(server, port=389)
[1218]108 if starttls:
[1221]109 con.start_tls_s()
110 con.simple_bind_s(binddn, bindpw)
111 results = con.search_s(basedn, ldap.SCOPE_SUBTREE, filterstr, attrlist)
[1218]112 return results
113
[1220]114class LdapSync(object):
[1221]115 def __init__(self, destserver,
116 destbinddn, destbindpw,
117 srcbasedn, destbasedn, options=Options()):
[1219]118 self.logger = logging.getLogger()
[1221]119
[1219]120 self.destserver = destserver
121 self.destbasedn = destbasedn
122 self.destbinddn = destbinddn
123 self.destbindpw = destbindpw
124 self.options = options
[1221]125
[1219]126 self.srcbasedn = srcbasedn
[1218]127
[1219]128 self.con = None
[1218]129
[1221]130 self.attrmap = ldap.cidict.cidict({})
131 self.classmap = {}
[1218]132
[1221]133 self.junk_attrs = ["memberof", "modifiersname", "modifytimestamp", "entryuuid",
134 "entrycsn", "contextcsn", "creatorsname", "createtimestamp",
135 "structuralobjectclass", "pwdchangedtime", "pwdfailuretime"]
136
137 self.reset_result()
138
139
140 def reset_result(self):
141 self.result = {
142 'add': {'ok': [], 'failed': []},
143 'update': {'ok': [], 'failed': []},
144 'delete': {'ok': [], 'failed': []},
145 }
146
147
148 def _dest_ldap_connect(self):
[1219]149 if self.con is None:
150 self.logger.info("connect to destination LDAP server {}".format(self.destserver))
[1221]151 self.con = ldap.open(self.destserver, port=389)
[1219]152 if self.options.starttls:
153 self.con.start_tls_s()
[1221]154 self.con.simple_bind_s(self.destbinddn, self.destbindpw)
[1218]155
[1219]156 def __adapt_dn(self, dn):
157 # move LDAP object to dest base
158 if self.srcbasedn != self.destbasedn:
159 dn_old = dn
[1221]160 rpath = dn[:-len(self.srcbasedn)]
161 dn = rpath+self.destbasedn
[1219]162 self.logger.debug("moved {} to {}".format(dn_old, dn))
163 # print "dn:",dn,"src:",srcbasedn,"rpath:",rpath,"dest:",destbasedn
164 return dn
[1218]165
[1219]166 def __is_dn_included(self, dn):
167 if self.options.exclude is None:
168 return True
169 if dn.lower().endswith(self.options.exclude):
170 return False
171 return True
[1218]172
[1219]173 def __adapt_source_ldap_objects(self, searchresult):
174 """
175 Do configured modification to the source LDAP objects.
176 """
177 self.logger.debug("modifying LDAP objects retrieved from source LDAP")
[1218]178
[1221]179 update_objects = []
180
[1219]181 for r in searchresult:
182 dn = self.__adapt_dn(r[0])
[1221]183 d = ldap.cidict.cidict(r[1])
[1218]184
[1219]185 if self.__is_dn_included(dn):
[1221]186 objectclasses = d["objectclass"]
[1218]187
[1221]188 newObjectclasses = []
[1219]189 for o in objectclasses:
190 if o.lower() in self.classmap:
[1221]191 new_oc = self.classmap[o.lower()]
192 if new_oc not in newObjectclasses:
193 newObjectclasses.append(new_oc)
[1219]194 else:
[1221]195 if o not in newObjectclasses:
[1219]196 newObjectclasses.append(o)
197
[1221]198 d["objectclass"] = newObjectclasses
[1219]199
200 for a in d.keys():
[1221]201 attr = a
[1219]202 if self.attrmap.has_key(a.lower()):
[1221]203 attr = self.attrmap[attr].lower()
204 if attr.lower() != a.lower():
205 values = d[a]
[1219]206 del d[a]
[1221]207 d[attr] = values
[1219]208
[1221]209 update_objects.append((dn, d))
[1219]210 return update_objects
211
212
[1221]213 def _get_dest_entry(self, dn, entry):
[1219]214 """
215 In the destination LDAP, the objects should be named
216 according to options.renameattr.
217 """
[1221]218 attrlist = self.options.attrlist
219
[1219]220 existingDestDn = None
221 existingDestEntry = None
222 if self.options.renameattr and entry.has_key(self.options.renameattr):
[1221]223 searchresult = self.con.search_s(
224 self.destbasedn,
225 ldap.SCOPE_SUBTREE,
226 '%s=%s' % (self.options.renameattr, entry[self.options.renameattr][0]), attrlist)
227 if searchresult is not None and len(searchresult) > 0:
[1219]228 existingDestDn, existingDestEntry = searchresult[0]
229 if existingDestDn.lower() != dn.lower():
[1221]230 self.con.modrdn_s(existingDestDn, dn)
231 self.notify_renamed(existingDestDn, dn,
232 existingDestEntry[self.options.renameattr][0],
233 entry[self.options.renameattr][0],
234 options)
[1219]235 if existingDestDn is None:
[1221]236 searchresult = self.con.search_s(dn, ldap.SCOPE_BASE, 'objectclass=*', attrlist)
[1219]237 existingDestDn, existingDestEntry = searchresult[0]
238 return (existingDestDn, existingDestEntry)
239
240
241 def __handle_pwdAccountLockedTime(self, dn, entry, now, max_age):
242 # hack for syncing accounts locked by password policy
243 do_unlock = False
[1221]244 if self.options.pwd_max_days > 0 and entry.has_key('pwdChangedTime'):
[1219]245 # print "pwdChangedTime set for",dn
246 pwdChange = entry['pwdChangedTime'][0]
247 d = dateutil.parser.parse(pwdChange)
[1221]248 if (now-d) > max_age:
249 entry['pwdAccountLockedTime'] = ['000001010000Z']
250 self.logger.info("locking {} {}".format(dn, pwdChange))
[1218]251 else:
[1219]252 # pwdAccountLockedTime is a operational attribute,
253 # and therefore not part of entry.
254 # Do extra search to retrieve attribute.
[1221]255 searchresult = self.con.search_s(
256 dn, ldap.SCOPE_BASE,
257 "objectclass=*", attrlist=['pwdAccountLockedTime'])
[1219]258 tmp_dn, tmp_entry = searchresult[0]
259 if tmp_entry.has_key('pwdAccountLockedTime'):
260 do_unlock = True
261 return do_unlock
[1218]262
263
[1221]264 def _syncLdapObject(self, srcDn, srcAttributes):
[1219]265 tzutc = dateutil.tz.gettz('UTC')
266 now = datetime.datetime.now(tzutc)
267 max_age = datetime.timedelta(days=self.options.pwd_max_days)
[1218]268
[1221]269 try:
270 destDn, destAttributes = self._get_dest_entry(srcDn, srcAttributes)
[1218]271
[1221]272 # hack for syncing accounts locked by password policy
273 do_unlock = self.__handle_pwdAccountLockedTime(srcDn, srcAttributes, now, max_age)
[1218]274
[1221]275 mod_attrs = ldap.modlist.modifyModlist(destAttributes, srcAttributes)
[1218]276
[1221]277 # hack for unlocking, see above
278 if do_unlock:
279 self.logger.info("unlocking {} {}".format(destDn, 'pwdAccountLockedTime'))
280 mod_attrs.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
[1218]281
[1221]282 if self.options.attrlist is not None:
283 mod_attrs = [a for a in mod_attrs if a[1].lower() in self.options.attrlist]
[1218]284
[1221]285 if self.junk_attrs is not None:
286 mod_attrs = [a for a in mod_attrs if a[1].lower() not in self.junk_attrs]
[1218]287
[1221]288 if mod_attrs:
289 try:
290 self.logger.debug('mod_attrs: ' + str(mod_attrs))
291 self.con.modify_s(srcDn, mod_attrs)
292 self.notify_modified(srcDn)
293 except:
294 self.logger.exception('modify failed')
295 self.notify_modified(srcDn, False)
[1218]296
[1221]297 except ldap.NO_SUCH_OBJECT:
298 if not self.options.updateonly:
[1219]299 try:
[1221]300 self.con.add_s(srcDn, ldap.modlist.addModlist(srcAttributes, self.junk_attrs))
301 self.notify_created(srcDn)
302 except (ldap.OBJECT_CLASS_VIOLATION,
303 ldap.NO_SUCH_OBJECT,
304 ldap.CONSTRAINT_VIOLATION):
305 self.notify_created(srcDn, False)
[1218]306
[1221]307
308 def __syncLdapDestination(self, update_objects):
309
310 logger.debug("writing data to destination LDAP")
311 for obj in update_objects:
312 dn, entry = obj
313 self._syncLdapObject(dn, entry)
314
315
[1219]316 def __deleteDestLdapObjects(self, update_objects):
317 """
318 Remove all LDAP objects in destination LDAP server
319 that did not come from the source LDAP objects
320 and are not excluded.
321 """
[1218]322
[1221]323 searchresult = self.con.search_s(self.destbasedn, ldap.SCOPE_SUBTREE, self.options.filter)
324 existing = [x[0].lower() for x in searchresult]
[1218]325
[1221]326 morituri = existing
[1218]327
[1219]328 if self.destbasedn.lower() in existing:
329 morituri.remove(self.destbasedn.lower())
[1218]330
[1221]331 for obj in update_objects:
332 dn, entry = obj
[1218]333 if dn.lower() in existing:
334 morituri.remove(dn.lower())
335 for dn in morituri:
[1219]336 if self.__is_dn_included(dn):
337 try:
338 self.con.delete_s(dn)
[1221]339 self.notify_deleted(dn)
[1219]340 except:
[1221]341 self.notify_deleted(dn, False)
[1218]342
[1221]343
[1219]344 def sync(self, searchresult):
345 """
346 Synchronize entries from searchresult to destination LDAP server.
347 """
[1221]348 if len(searchresult) == 0:
[1219]349 self.logger.error("empty source, aborting")
350 return
351
[1221]352 self._dest_ldap_connect()
[1219]353
354 update_objects = self.__adapt_source_ldap_objects(searchresult)
[1221]355 self.__syncLdapDestination(update_objects)
356 if self.options.delete and not self.options.updateonly:
357 self.__deleteDestLdapObjects(update_objects)
[1219]358 self.con.unbind()
[1218]359
[1221]360 self.__log_summary(True)
[1218]361
[1219]362
[1221]363 def __log_summary(self, show_failed=True, show_ok=False):
364 result = self.result
[1219]365 for action in result.keys():
366 ok = len(result[action]['ok'])
367 failed = len(result[action]['failed'])
368 print "{} (ok: {}, failed: {}):".format(action, ok, failed)
369
370 if show_ok and ok > 0:
371 print "succeeded:"
372 print "\n".join(result[action]['ok'])
373
374 if show_failed and failed > 0:
375 print "failed:"
376 print "\n".join(result[action]['failed'])
377
[1221]378 def get_short_dn(self, dn):
379 return dn.lower().replace(',' + self.srcbasedn.lower(), '')
[1219]380
[1221]381 def notify_created(self, dn, ok=True):
382 if ok:
383 logger.debug('{} created'.format(self.get_short_dn(dn)))
384 self.result['add']['ok'].append(dn)
385 else:
386 self.logger.warning("failed to add {}".format(dn))
387 self.result['add']['failed'].append(dn)
[1219]388
[1221]389 def notify_modified(self, dn, ok=True):
390 if ok:
391 logger.debug('{} modified'.format(self.get_short_dn(dn)))
392 self.result['update']['ok'].append(dn)
393 else:
394 self.logger.error("failed to modify {}".format(dn))
395 self.result['update']['failed'].append(dn)
[1219]396
[1221]397 def notify_deleted(self, dn, ok=True):
398 if ok:
399 logger.debug('{} deleted'.format(self.get_short_dn(dn)))
400 self.result['delete']['ok'].append(dn)
401 else:
402 self.logger.error("failed to delete {}".format(dn))
403 self.result['delete']['failed'].append(dn)
[1219]404
405 def notify_renamed(self, dn, newdn, uid, newuid, options):
[1221]406 print "renamed", dn, newdn
407 subprocess.check_call(
408 "%s %s %s %s %s" % (options.renamecommand, dn, newdn, uid, newuid),
409 shell=True)
[1219]410
411
[1221]412
413class SyncReplConsumer(ReconnectLDAPObject, SyncreplConsumer):
414 """
415 Syncrepl Consumer interface
416 """
417
418 def __init__(self, dest, syncrepl_entry_callback, *args, **kwargs):
419 self.logger = logging.getLogger()
420 # Initialise the LDAP Connection first
421 ldap.ldapobject.ReconnectLDAPObject.__init__(self, *args, **kwargs)
422 # We need this for later internal use
423 self.__presentUUIDs = dict()
424 self.cookie = None
425 self.dest_ldap = dest
426 self.syncrepl_entry_callback = syncrepl_entry_callback
427
428 def syncrepl_get_cookie(self):
429 return self.cookie
430
431 def syncrepl_set_cookie(self, cookie):
432 self.cookie = cookie
433
434 def syncrepl_entry(self, dn, attributes, uuid):
435 # First we determine the type of change we have here
436 # (and store away the previous data for later if needed)
437 if uuid in self.__presentUUIDs:
438 change_type = 'modify'
439 else:
440 change_type = 'add'
441 # Now we store our knowledge of the existence of this entry
442 self.__presentUUIDs[uuid] = dn
443 # Debugging
444 logger.debug('{}: {} ({})'.format(dn, change_type, ",".join(attributes.keys())))
445 # If we have a cookie then this is not our first time being run,
446 # so it must be a change
447 if self.cookie is not None:
448 self.syncrepl_entry_callback(dn, attributes)
449
450
451 def syncrepl_delete(self, uuids):
452 """ syncrepl_delete """
453 # Make sure we know about the UUID being deleted, just in case...
454 uuids = [uuid for uuid in uuids if uuid in self.__presentUUIDs]
455 # Delete all the UUID values we know of
456 for uuid in uuids:
457 logger.debug('detected deletion of entry {} ({})', uuid, self.__presentUUIDs[uuid])
458 del self.__presentUUIDs[uuid]
459
460 def syncrepl_present(self, uuids, refreshDeletes=False):
461 """ called on initial sync """
462 if uuids is not None:
463 self.logger.debug('uuids: {}'.format(','.join(uuids)))
464 # If we have not been given any UUID values,
465 # then we have recieved all the present controls...
466 if uuids is None:
467 # We only do things if refreshDeletes is false as the syncrepl
468 # extension will call syncrepl_delete instead when it detects a
469 # delete notice
470 if not refreshDeletes:
471 deletedEntries = [
472 uuid for uuid in self.__presentUUIDs
473 ]
474 self.syncrepl_delete(deletedEntries)
475 # Phase is now completed, reset the list
476 self.__presentUUIDs = {}
477 else:
478 # Note down all the UUIDs we have been sent
479 for uuid in uuids:
480 self.__presentUUIDs[uuid] = True
481
482
483 def syncrepl_refreshdone(self):
484 self.logger.info('Initial synchronization is now done, persist phase begins')
485 #self.logger.debug('UUIDs:\n' + '\n'.join(self.__presentUUIDs))
486
487
488
489class LdapSyncRepl(LdapSync):
490 def __init__(self, destsrv,
491 destadmindn, destadminpw,
492 basedn, destbasedn,
493 options=Options(), source_ldap_url_obj=None):
494 # Install our signal handlers
495 signal.signal(signal.SIGTERM, self.shutdown)
496 self.watcher_running = False
497 self.source_ldap_url_obj = source_ldap_url_obj
498 self.ldap_credentials = False
499 self.source_ldap_connection = None
500 super(LdapSyncRepl, self).__init__(destsrv,
501 destadmindn, destadminpw,
502 basedn, destbasedn, options)
503
504
505 def sync(self):
506 self._dest_ldap_connect()
507 self.watcher_running = True
508 while self.watcher_running:
509 self.logger.info('Connecting to source LDAP server')
510 # Prepare the LDAP server connection (triggers the connection as well)
511 self.source_ldap_connection = SyncReplConsumer(self.con,
512 self.perform_application_sync_callback,
513 self.source_ldap_url_obj.initializeUrl())
514
515 if self.source_ldap_url_obj.who and self.source_ldap_url_obj.cred:
516 self.ldap_credentials = True
517 # Now we login to the LDAP server
518 try:
519 self.source_ldap_connection.simple_bind_s(
520 self.source_ldap_url_obj.who, self.source_ldap_url_obj.cred)
521 except ldap.INVALID_CREDENTIALS, e:
522 print 'Login to LDAP server failed: ', str(e)
523 sys.exit(1)
524 except ldap.SERVER_DOWN:
525 print 'LDAP server is down, going to retry.'
526 time.sleep(5)
527 continue
528
529 # Commence the syncing
530 self.logger.info('Staring sync process')
531 ldap_search = self.source_ldap_connection.syncrepl_search(
532 self.source_ldap_url_obj.dn or '',
533 self.source_ldap_url_obj.scope or ldap.SCOPE_SUBTREE,
534 mode='refreshAndPersist',
535 attrlist=self.source_ldap_url_obj.attrs,
536 filterstr=self.source_ldap_url_obj.filterstr or '(objectClass=*)'
537 )
538
539 try:
540 while self.source_ldap_connection.syncrepl_poll(all=1, msgid=ldap_search):
541 print ".",
542 except KeyboardInterrupt:
543 # User asked to exit
544 print "aborted\n"
545 self.shutdown(None, None)
546 except Exception, e:
547 # Handle any exception
548 if self.watcher_running:
549 self.logger.exception('Encountered a problem, going to retry.')
550 time.sleep(5)
551
552 def perform_application_sync_callback(self, dn, attributes):
553 logger.debug('{}: src: {}'.format(dn, str(attributes)))
554 try:
555 self._syncLdapObject(dn, attributes)
556 except ldap.NO_SUCH_OBJECT:
557 self.logger.info("SKIPPED: {} object does not exist on target".format(dn))
558 return False
559 return True
560
561 def shutdown(self, signum, stack):
562 # Declare the needed global variables
563 self.logger.info('Shutting down!')
564
565 # We are no longer running
566 self.watcher_running = False
567
568
[1218]569if __name__ == "__main__":
[1219]570 logging.basicConfig(format='%(levelname)s %(module)s.%(funcName)s: %(message)s', level=logging.INFO)
571 logger = logging.getLogger()
572
[1221]573 args = getArguments()
[1219]574 if args.debug:
575 logger.setLevel(logging.DEBUG)
[1221]576 conffile = args.configfile
[1219]577
[1218]578 exclude = None
579
[1221]580 config = ConfigParserDefaults()
[1218]581 config.read(conffile)
582
583 srcfile = None
584 try:
[1221]585 srcfile = config.get("source", "file")
[1218]586 except:
587 pass
588
[1221]589 basedn = config.get("source", "baseDn")
590 filterstr = config.get0("source", "filter", None)
[1218]591
[1221]592 if srcfile is None:
593 srv = config.get("source", "server")
594 admindn = config.get("source", "bindDn")
595 adminpw = config.get("source", "bindPassword")
596 starttls = config.getboolean("source", "starttls")
[1218]597
[1221]598 destsrv = config.get("destination", "server")
599 destadmindn = config.get("destination", "bindDn")
600 destadminpw = config.get("destination", "bindPassword")
601 destbasedn = config.get("destination", "baseDn")
602 destdelete = config.getboolean("destination", "delete")
[1218]603 try:
[1221]604 rdn = config.get("destination", "rdn")
[1219]605 logger.warning("setting rdn is currently ignored")
[1218]606 except:
[1219]607 pass
[1218]608
[1219]609 options = Options()
[1218]610 try:
[1221]611 options.exclude = config.get("destination", "excludesubtree").lower()
[1218]612 except:
[1219]613 pass
[1218]614
[1221]615 options.updateonly = not config.getboolean("destination", "create", False)
616 options.starttls = config.getboolean("destination", "starttls", False)
617 options.renameattr = config.get0("destination", "detectRename", None)
618 options.renamecommand = config.get0("destination", "detectRename", None)
619 options.pwd_max_days = int(config.get("source", "pwd_max_days", 0))
620 options.filter = filterstr
[1219]621
[1221]622 # Set source.attrlist as global option.
623 # If source would use less attributes than dest,
624 # all attributes not retrieved from source would be deleted from dest
[1218]625 try:
[1221]626 options.attrlist = config.get("source", "attributes").split(",")
[1218]627 except:
[1221]628 options.attrlist = None
[1218]629
[1221]630 if config.get0('source', 'mode') == 'syncrepl':
631 ldapsync = LdapSyncRepl(
632 destsrv, destadmindn, destadminpw, basedn, destbasedn,
633 options,
634 source_ldap_url_obj=config.get_ldap_url_obj('source'))
635 ldapsync.sync()
[1218]636 else:
[1221]637 if srcfile:
638 objects = readLDIFSource(srcfile)
639 else:
640 objects = readLdapSource(srv, admindn, adminpw,
641 basedn, filterstr, options.attrlist, starttls)
[1218]642
[1221]643 ldapsync = LdapSync(destsrv, destadmindn, destadminpw, basedn, destbasedn, options)
644 ldapsync.sync(objects)
Note: See TracBrowser for help on using the repository browser.