resync/resync/client.py

634 lines
28 KiB
Python

"""ResourceSync client implementation"""
import sys
import urllib
import urlparse
import os.path
import datetime
import distutils.dir_util
import re
import time
import logging
import ConfigParser
from resync.resource_list_builder import ResourceListBuilder
from resync.resource_list import ResourceList
from resync.change_list import ChangeList
from resync.capability_list import CapabilityList
from resync.capability_list_index import CapabilityListIndex
from resync.mapper import Mapper
from resync.sitemap import Sitemap
from resync.dump import Dump
from resync.resource import Resource
from resync.url_authority import UrlAuthority
from resync.utils import compute_md5_for_file
from resync.client_state import ClientState
from w3c_datetime import str_to_datetime,datetime_to_str
class ClientFatalError(Exception):
"""Non-recoverable error in client, should include message to user"""
pass
class Client(object):
"""Implementation of a ResourceSync client
Logging is used for both console output and for detailed logs for
automated analysis. Levels used:
warning - usually shown to user
info - verbose output
debug - very verbose for automated analysis
"""
def __init__(self, checksum=False, verbose=False, dryrun=False):
super(Client, self).__init__()
self.checksum = checksum
self.verbose = verbose
self.dryrun = dryrun
self.logger = logging.getLogger('client')
self.mapper = Mapper()
self.resource_list_name = 'resourcelist.xml'
self.change_list_name = 'changelist.xml'
self.dump_format = None
self.exclude_patterns = []
self.sitemap_name = None
self.allow_multifile = True
self.noauth = False
self.max_sitemap_entries = None
self.ignore_failures = False
self.status_file = '.resync-client-status.cfg'
self.pretty_xml = True
def set_mappings(self,mappings):
"""Build and set Mapper object based on input mappings"""
self.mapper = Mapper(mappings, use_default_path=True)
def sitemap_uri(self,basename):
"""Get full URI (filepath) for sitemap based on basename"""
if (re.match(r"\w+:",basename)):
# looks like URI
return(basename)
elif (re.match(r"/",basename)):
# looks like full path
return(basename)
else:
# build from mapping with name appended
return(self.mapper.default_src_uri() + '/' + basename)
@property
def sitemap(self):
"""Return the sitemap URI based on maps or explicit settings"""
if (self.sitemap_name is not None):
return(self.sitemap_name)
return(self.sitemap_uri(self.resource_list_name))
def build_resource_list(self, paths=None):
"""Return a resource list for files on local disk
The set of files is taken by disk scan from the paths specified or
else defaults to the paths specified in the current mappings
Return resource_list. Uses existing self.mapper settings.
"""
# 0. Sanity checks, parse paths is specified
if (len(self.mapper)<1):
raise ClientFatalError("No source to destination mapping specified")
if (paths is not None):
# Expect comma separated list of paths
paths=paths.split(',')
# 1. Build from disk
rlb = ResourceListBuilder(set_md5=self.checksum,mapper=self.mapper)
rlb.add_exclude_files(self.exclude_patterns)
rl = rlb.from_disk(paths=paths)
# 2. Set defaults and overrides
rl.allow_multifile = self.allow_multifile
rl.pretty_xml = self.pretty_xml
rl.mapper = self.mapper
if (self.max_sitemap_entries is not None):
rl.max_sitemap_entries = self.max_sitemap_entries
return(rl)
def log_event(self, change):
"""Log a Resource object as an event for automated analysis"""
self.logger.debug( "Event: "+repr(change) )
def baseline_or_audit(self, allow_deletion=False, audit_only=False):
"""Baseline synchonization or audit
Both functions implemented in this routine because audit is a prerequisite
for a baseline sync. In the case of baseline sync the last timestamp seen
is recorded as client state.
"""
action = ( 'audit' if (audit_only) else 'baseline sync' )
self.logger.debug("Starting "+action)
### 0. Sanity checks
if (len(self.mapper)<1):
raise ClientFatalError("No source to destination mapping specified")
if (not audit_only and self.mapper.unsafe()):
raise ClientFatalError("Source to destination mappings unsafe: %s" % str(self.mapper))
### 1. Get inventories from both src and dst
# 1.a source resource_list
try:
self.logger.info("Reading sitemap %s" % (self.sitemap))
src_resource_list = ResourceList(allow_multifile=self.allow_multifile, mapper=self.mapper)
src_resource_list.read(uri=self.sitemap)
self.logger.debug("Finished reading sitemap")
except Exception as e:
raise ClientFatalError("Can't read source resource_list from %s (%s)" % (self.sitemap,str(e)))
self.logger.info("Read source resource_list, %d resources listed" % (len(src_resource_list)))
if (len(src_resource_list)==0):
raise ClientFatalError("Aborting as there are no resources to sync")
if (self.checksum and not src_resource_list.has_md5()):
self.checksum=False
self.logger.info("Not calculating checksums on destination as not present in source resource_list")
# 1.b destination resource_list mapped back to source URIs
rlb = ResourceListBuilder(set_md5=self.checksum, mapper=self.mapper)
dst_resource_list = rlb.from_disk()
### 2. Compare these resource_lists respecting any comparison options
(same,updated,deleted,created)=dst_resource_list.compare(src_resource_list)
### 3. Report status and planned actions
self.log_status(in_sync=(len(updated)+len(deleted)+len(created)==0),
audit=True,same=len(same),created=len(created),
updated=len(updated),deleted=len(deleted))
if (audit_only or len(created)+len(updated)+len(deleted)==0):
self.logger.debug("Completed "+action)
return
### 4. Check that sitemap has authority over URIs listed
uauth = UrlAuthority(self.sitemap)
for resource in src_resource_list:
if (not uauth.has_authority_over(resource.uri)):
if (self.noauth):
#self.logger.info("Sitemap (%s) mentions resource at a location it does not have authority over (%s)" % (self.sitemap,resource.uri))
pass
else:
raise ClientFatalError("Aborting as sitemap (%s) mentions resource at a location it does not have authority over (%s), override with --noauth" % (self.sitemap,resource.uri))
### 5. Grab files to do sync
delete_msg = (", and delete %d resources" % len(deleted)) if (allow_deletion) else ''
self.logger.warning("Will GET %d resources%s" % (len(created)+len(updated),delete_msg))
self.last_timestamp = 0
num_created=0
num_updated=0
num_deleted=0
for resource in created:
uri = resource.uri
file = self.mapper.src_to_dst(uri)
self.logger.info("created: %s -> %s" % (uri,file))
num_created+=self.update_resource(resource,file,'created')
for resource in updated:
uri = resource.uri
file = self.mapper.src_to_dst(uri)
self.logger.info("updated: %s -> %s" % (uri,file))
num_updated+=self.update_resource(resource,file,'updated')
for resource in deleted:
uri = resource.uri
file = self.mapper.src_to_dst(uri)
num_deleted+=self.delete_resource(resource,file,allow_deletion)
### 6. Store last timestamp to allow incremental sync
if (not audit_only and self.last_timestamp>0):
ClientState().set_state(self.sitemap,self.last_timestamp)
self.logger.info("Written last timestamp %s for incremental sync" % (datetime_to_str(self.last_timestamp)))
### 7. Done
self.log_status(in_sync=(len(updated)+len(deleted)+len(created)==0),
same=len(same),created=num_created,
updated=num_updated,deleted=num_deleted)
self.logger.debug("Completed %s" % (action))
def incremental(self, allow_deletion=False, change_list_uri=None, from_datetime=None):
"""Incremental synchronization
"""
self.logger.debug("Starting incremental sync")
### 0. Sanity checks
if (len(self.mapper)<1):
raise ClientFatalError("No source to destination mapping specified")
if (self.mapper.unsafe()):
raise ClientFatalError("Source to destination mappings unsafe: %s" % str(self.mapper))
from_timestamp = None
if (from_datetime is not None):
try:
from_timestamp = str_to_datetime(from_datetime)
except ValueError:
raise ClientFatalError("Bad datetime in --from (%s)" % from_datetime)
### 1. Work out where to start from
if (from_timestamp is None):
from_timestamp=ClientState().get_state(self.sitemap)
if (from_timestamp is None):
raise ClientFatalError("No stored timestamp for this site, and no explicit --from")
### 2. Get URI of change list, from sitemap or explicit
if (change_list_uri):
# Translate as necessary using maps
change_list = self.sitemap_uri(change_list_uri)
else:
# Try default name
change_list = self.sitemap_uri(self.change_list_name)
### 3. Read change list from source
try:
self.logger.info("Reading change list %s" % (change_list))
src_change_list = ChangeList()
src_change_list.read(uri=change_list)
self.logger.debug("Finished reading change list")
except Exception as e:
raise ClientFatalError("Can't read source change list from %s (%s)" % (change_list,str(e)))
self.logger.info("Read source change list, %d changes listed" % (len(src_change_list)))
#if (len(src_change_list)==0):
# raise ClientFatalError("Aborting as there are no resources to sync")
if (self.checksum and not src_change_list.has_md5()):
self.checksum=False
self.logger.info("Not calculating checksums on destination as not present in source change list")
# Check all changes have timestamp and record last
self.last_timestamp = 0
for resource in src_change_list:
if (resource.timestamp is None):
raise ClientFatalError("Aborting - missing timestamp for change in %s" % (uri))
if (resource.timestamp > self.last_timestamp):
self.last_timestamp = resource.timestamp
### 4. Check that the change list has authority over URIs listed
# FIXME - What does authority mean for change list? Here use both the
# change list URI and, if we used it, the sitemap URI
uauth_cs = UrlAuthority(change_list)
if (not change_list_uri):
uauth_sm = UrlAuthority(self.sitemap)
for resource in src_change_list:
if (not uauth_cs.has_authority_over(resource.uri) and
(change_list_uri or not uauth_sm.has_authority_over(resource.uri))):
if (self.noauth):
#self.logger.info("Change list (%s) mentions resource at a location it does not have authority over (%s)" % (change_list,resource.uri))
pass
else:
raise ClientFatalError("Aborting as change list (%s) mentions resource at a location it does not have authority over (%s), override with --noauth" % (change_list,resource.uri))
### 5. Prune entries before starting timestamp and dupe changes for a resource
num_skipped = src_change_list.prune_before(from_timestamp)
if (num_skipped>0):
self.logger.info("Skipped %d changes before %s" % (num_skipped,datetime_to_str(from_timestamp)))
num_dupes = src_change_list.prune_dupes()
if (num_dupes>0):
self.logger.info("Removed %d prior changes" % (num_dupes))
### 6. Apply changes at same time or after from_timestamp
self.logger.info("Applying %d changes" % (len(src_change_list)))
num_updated = 0
num_deleted = 0
num_created = 0
for resource in src_change_list:
uri = resource.uri
file = self.mapper.src_to_dst(uri)
if (resource.change == 'updated'):
self.logger.info("updated: %s -> %s" % (uri,file))
self.update_resource(resource,file,'updated')
num_updated+=1
elif (resource.change == 'created'):
self.logger.info("created: %s -> %s" % (uri,file))
self.update_resource(resource,file,'created')
num_created+=1
elif (resource.change == 'deleted'):
self.delete_resource(resource,file,allow_deletion)
num_deleted+=1
else:
raise ClientError("Unknown change type %s" % (resource.change) )
### 7. Report status and planned actions
self.log_status(in_sync=((num_updated+num_deleted+num_created)==0),
incremental=True,created=num_created, updated=num_updated,
deleted=num_deleted)
### 8. Record last timestamp we have seen
if (self.last_timestamp>0):
ClientState().set_state(self.sitemap,self.last_timestamp)
self.logger.info("Written last timestamp %s for incremental sync" % (datetime_to_str(self.last_timestamp)))
### 9. Done
self.logger.debug("Completed incremental sync")
def update_resource(self, resource, file, change=None):
"""Update resource from uri to file on local system
Update means three things:
1. GET resources
2. set mtime in local time to be equal to timestamp in UTC (should perhaps
or at least warn if different from LastModified from the GET response instead
but maybe warn if different (or just earlier than) the lastmod we expected
from the resource_list
3. check that resource matches expected information
Also update self.last_timestamp if the timestamp (in source frame) of this
resource is later and the current value.
Returns the number of resources updated/created (0 or 1)
"""
path = os.path.dirname(file)
distutils.dir_util.mkpath(path)
num_updated=0
if (self.dryrun):
self.logger.info("dryrun: would GET %s --> %s" % (resource.uri,file))
else:
# 1. GET
try:
urllib.urlretrieve(resource.uri,file)
num_updated+=1
except IOError as e:
msg = "Failed to GET %s -- %s" % (resource.uri,str(e))
if (self.ignore_failures):
self.logger.warning(msg)
return
else:
raise ClientFatalError(msg)
# 2. set timestamp if we have one
if (resource.timestamp is not None):
unixtime = int(resource.timestamp) #no fractional
os.utime(file,(unixtime,unixtime))
if (resource.timestamp > self.last_timestamp):
self.last_timestamp = resource.timestamp
self.log_event(Resource(resource=resource, change=change))
# 3. sanity check
length = os.stat(file).st_size
if (resource.length != length):
self.logger.info("Downloaded size for %s of %d bytes does not match expected %d bytes" % (resource.uri,length,resource.length))
if (self.checksum and resource.md5 is not None):
file_md5 = compute_md5_for_file(file)
if (resource.md5 != file_md5):
self.logger.info("MD5 mismatch for %s, got %s but expected %s bytes" % (resource.uri,file_md5,resource.md5))
return(num_updated)
def delete_resource(self, resource, file, allow_deletion=False):
"""Delete copy of resource in file on local system
Will only actually do the deletion if allow_deletion is True. Regardless
of whether the deletion occurs, self.last_timestamp will be updated
if the resource.timestamp is later than the current value.
Returns the number of files actually deleted (0 or 1).
"""
num_deleted=0
uri = resource.uri
if (resource.timestamp is not None and
resource.timestamp > self.last_timestamp):
self.last_timestamp = resource.timestamp
if (allow_deletion):
if (self.dryrun):
self.logger.info("dryrun: would delete %s -> %s" % (uri,file))
else:
try:
os.unlink(file)
num_deleted+=1
except OSError as e:
msg = "Failed to DELETE %s -> %s : %s" % (uri,file,str(e))
#if (self.ignore_failures):
self.logger.warning(msg)
# return
#else:
# raise ClientFatalError(msg)
self.logger.info("deleted: %s -> %s" % (uri,file))
self.log_event(Resource(resource=resource, change="deleted"))
else:
self.logger.info("nodelete: would delete %s (--delete to enable)" % uri)
return(num_deleted)
def parse_document(self):
"""Parse any ResourceSync document and show information
Will use sitemap URI taken either from explicit self.sitemap_name
or derived from the mappings supplied.
"""
s=Sitemap()
self.logger.info("Reading sitemap(s) from %s ..." % (self.sitemap))
try:
list = s.parse_xml(urllib.urlopen(self.sitemap))
except IOError as e:
raise ClientFatalError("Cannot read document (%s)" % str(e))
num_entries = len(list.resources)
capability = '(unknown capability)'
if ('capability' in list.md):
capability = list.md['capability']
print "Parsed %s document with %d entries" % (capability,num_entries)
if (self.verbose):
to_show = 100
override_str = ' (override with --max-sitemap-entries)'
if (self.max_sitemap_entries):
to_show = self.max_sitemap_entries
override_str = ''
if (num_entries>to_show):
print "Showing first %d entries sorted by URI%s..." % (to_show,override_str)
n=0
for resource in list:
print '[%d] %s' % (n,str(resource))
n+=1
if ( n >= to_show ):
break
def explore(self):
"""Explore capabilities of a server interactvely
Will use sitemap URI taken either from explicit self.sitemap_name
or derived from the mappings supplied.
"""
uri = None
if (self.sitemap_name is not None):
uri = self.sitemap
print "Taking location from --sitemap option"
acceptable_capabilities = None #ie. any
elif (len(self.mapper)>0):
pu = urlparse.urlparse(self.mapper.default_src_uri())
uri = urlparse.urlunparse( [ pu[0], pu[1], '/.well-known/resourcesync', '', '', '' ] )
print "Will look for discovery information based on mappings"
acceptable_capabilities = [ 'capabilitylist', 'capabilitylistindex' ]
else:
raise FatalError("Neither explicit sitemap nor mapping specified")
inp = None
while (inp!='q'):
print
(uri, acceptable_capabilities, inp) = self.explore_uri(uri,acceptable_capabilities)
def explore_uri(self, uri, caps):
"""Interactive exploration of document at uri
Will flag warnings if the document is not of type listed in caps
"""
s=Sitemap()
print "Reading %s" % (uri)
try:
list = s.parse_xml(urllib.urlopen(uri))
except IOError as e:
raise ClientFatalError("Cannot read %s (%s)" % (uri,str(e)))
num_entries = len(list.resources)
capability = '(unknown capability)'
if ('capability' in list.md):
capability = list.md['capability']
if (s.parsed_index):
capability += 'index'
print "Parsed %s document with %d entries:" % (capability,num_entries)
if (caps is not None and capability not in caps):
print "WARNING - expected a %s document" % (','.join(caps))
to_show = num_entries
if (num_entries>21):
to_show = 20
# What entries are allowed?
# FIXME - not complete
if (capability == 'capabilitylistindex'):
entry_caps = ['capabilitylist']
elif (capability == 'capabilitylist'):
entry_caps = ['resourcelist','changelist','resourcedump','changedump','changelistindex']
elif (capability == 'changelistindex'):
entry_caps = ['changelist']
n = 0
options = {}
for r in list.resources:
if (n>=to_show):
print "(not showing remaining %d entries)" % (num_entries-n)
last
n+=1
options[str(n)]=r
print "[%d] %s" % (n,r.uri)
if (r.capability is not None):
warning = ''
if (r.capability not in entry_caps):
warning = " (EXPECTED %s)" % (' or '.join(entry_caps))
print " %s%s" % (r.capability,warning)
elif (len(entry_caps)==1):
r.capability=entry_caps[0]
print " capability not specified, should be %s" % (r.capability)
while (True):
inp = raw_input( "Follow [number or q(uit)]?" )
if (inp in options.keys()):
break
if (inp == 'q'):
return('','',inp)
caps = [ options[inp].capability ]
if (capability == 'capabilitylistindex'):
# all links should be to capabilitylist documents
if (caps is None):
caps = ['capabilitylist']
return( options[inp].uri, caps, inp )
def write_resource_list(self,paths=None,outfile=None,links=None,dump=None):
"""Write a resource list sitemap for files on local disk
Set of resources included is based on paths setting or else the mappings.
Optionally links can be added. Output will be to stdout unless outfile
is specified.
"""
rl = self.build_resource_list(paths=paths)
if (links is not None):
rl.ln = links
if (outfile is None):
print rl.as_xml()
else:
rl.write(basename=outfile)
self.write_dump_if_requested(rl,dump)
def write_change_list(self,paths=None,outfile=None,ref_sitemap=None,newref_sitemap=None,
empty=None,links=None,dump=None):
"""Write a change list
Unless the both ref_sitemap and newref_sitemap are specified then the Change
List is calculated between the reference an the current state of files on
disk. The files on disk are scanned based either on the paths setting or
else on the mappings.
"""
cl = ChangeList(ln=links)
if (not empty):
# 1. Get and parse reference sitemap
old_rl = self.read_reference_resource_list(ref_sitemap)
# 2. Depending on whether a newref_sitemap was specified, either read that
# or build resource_list from files on disk
if (newref_sitemap is None):
# Get resource list from disk
new_rl = self.build_resource_list(paths=paths)
else:
new_rl = self.read_reference_resource_list(newref_sitemap,name='new reference')
# 3. Calculate change list
(same,updated,deleted,created)=old_rl.compare(new_rl)
cl.add_changed_resources( updated, change='updated' )
cl.add_changed_resources( deleted, change='deleted' )
cl.add_changed_resources( created, change='created' )
# 4. Write out change list
cl.mapper = self.mapper
cl.pretty_xml = self.pretty_xml
if (self.max_sitemap_entries is not None):
cl.max_sitemap_entries = self.max_sitemap_entries
if (outfile is None):
print cl.as_xml()
else:
cl.write(basename=outfile)
self.write_dump_if_requested(cl,dump)
def write_capability_list(self,capabilities=None,outfile=None,links=None):
"""Write a Capability List to outfile or STDOUT"""
capl = CapabilityList(ln=links)
capl.pretty_xml = self.pretty_xml
if (capabilities is not None):
for name in capabilities.keys():
capl.add_capability(name=name, uri=capabilities[name])
if (outfile is None):
print capl.as_xml()
else:
capl.write(basename=outfile)
def write_capability_list_index(self,capability_lists=None,outfile=None,links=None):
"""Write a Capability List to outfile or STDOUT"""
capli = CapabilityListIndex(ln=links)
capli.pretty_xml = self.pretty_xml
if (capability_lists is not None):
for uri in capability_lists:
capli.add_capability_list(uri)
if (outfile is None):
print capli.as_xml()
else:
capli.write(basename=outfile)
def write_dump_if_requested(self,resource_list,dump):
if (dump is None):
return
self.logger.info("Writing dump to %s..." % (dump))
d = Dump(format=self.dump_format)
d.write(resource_list=resource_list,dumpfile=dump)
def read_reference_resource_list(self,ref_sitemap,name='reference'):
"""Read reference resource list and return the ResourceList object
name parameter just uses in output messages to say what type
of resource list is being read.
"""
rl = ResourceList()
self.logger.info("Reading reference %s resource list from %s ..." % (name,ref_sitemap))
rl.mapper=self.mapper
rl.read(uri=ref_sitemap,index_only=(not self.allow_multifile))
num_entries = len(rl.resources)
self.logger.info("Read %s resource list with %d entries in %d sitemaps" % (name,num_entries,rl.num_files))
if (self.verbose):
to_show = 100
override_str = ' (override with --max-sitemap-entries)'
if (self.max_sitemap_entries):
to_show = self.max_sitemap_entries
override_str = ''
if (num_entries>to_show):
print "Showing first %d entries sorted by URI%s..." % (to_show,override_str)
n=0
for r in rl.resources:
print r
n+=1
if ( n >= to_show ):
break
return(rl)
def log_status(self, in_sync=True, incremental=False, audit=False,
same=None, created=0, updated=0, deleted=0):
"""Write log message regarding status in standard form
Split this off so we messages from baseline/audit/incremental
are written in a consistent form.
"""
if (incremental):
status = "NO CHANGES" if in_sync else "CHANGES"
else:
status = "IN SYNC" if in_sync else ("NOT IN SYNC" if (audit) else "SYNCED")
if (audit):
words = { 'created': 'to create',
'updated': 'to update',
'deleted': 'to delete' }
else:
words = { 'created': 'created',
'updated': 'updated',
'deleted': 'deleted' }
same = "" if (same is None) else ("same=%d, " % same)
self.logger.warning("Status: %11s (%s%s=%d, %s=%d, %s=%d)" %\
(status, same, words['created'], created,
words['updated'], updated, words['deleted'], deleted))
if __name__ == '__main__':
main()