#!/usr/bin/pythonw from appscript import app, k, CommandError from aemreceive.sfba import * from Foundation import NSLog, NSUserDefaults, NSDate from subprocess import call from traceback import print_exc from cStringIO import StringIO from datetime import datetime import RSS import email, smtplib from email.MIMEMessage import MIMEMessage from email.MIMEText import MIMEText from email.Utils import formataddr, formatdate import os, re, sys import objc # XXX should switch to public DADiskMount/Eject functions objc.loadBundle('DiskManagement', globals(), '/System/Library/PrivateFrameworks/DiskManagement.framework') EVENTS_DIR = os.path.expanduser('~/Sites/RetroStatus') EVENTS_MAX = 100 FROM_NAME, FROM_ADDR = 'Retrospect backup server', 'retrospect' + '@rileys.us' TO_ADDR = ['%s@rileys.us' % user for user in ['julia', 'gary']] SMTP_SERVER = 'calamity.bos.sabi.net' DEBUG = True # XXX implement Web page with current status ### notifications GROWL_APP_NAME = 'RetroStatus' NOTIFICATION_BACKUP = 'Backup' NOTIFICATION_MEDIA = 'Media change' NOTIFICATIONS_ALL = [NOTIFICATION_BACKUP, NOTIFICATION_MEDIA] EVENTS_FILE = os.path.join(EVENTS_DIR, 'index.rdf') EVENTS_FILE_TEMP = EVENTS_FILE + '~' growl = app('GrowlHelperApp') events = RSS.TrackingChannel() try: os.mkdir(EVENTS_DIR) except: pass events.setMD((RSS.ns.rss10, 'channel'), {(RSS.ns.rss10, 'title'): 'Retrospect status', (RSS.ns.rss10, 'link'): os.path.basename(EVENTS_FILE)}) def growlNotify(name, title, description, **kw): # XXX restore coalescing support from LocationDo params = dict(with_name=name, title=title, description=unicode(description), application_name=GROWL_APP_NAME, icon_of_application='Retrospect') params.update(kw) if DEBUG: NSLog('%s: %s' % (title, description)) try: growl.notify(**params) except CommandError: pass def addEvent(title, description, preformatted=False): description = description.replace('&', '&').replace('<', '<') if preformatted: description = '
%s
' % description else: description = description.replace('\n\n', '

').replace('\n', '
') # XXX provide valid URL for event details events.addItem({(RSS.ns.dc, 'date'): datetime.today().isoformat(), (RSS.ns.rss10, 'title'): title, # (RSS.ns.rss10, 'link'): datetime.today().isoformat(), (RSS.ns.rss10, 'description'): description}) events.truncateToLength(EVENTS_MAX) file(EVENTS_FILE_TEMP, 'w').write(str(events)) os.rename(EVENTS_FILE_TEMP, EVENTS_FILE) def sendMail(subject, text): message = MIMEText(text.encode('utf8'), _charset='utf8') message.set_unixfrom(FROM_ADDR) message['From'] = formataddr((FROM_NAME, FROM_ADDR)) message['To'] = ', '.join(TO_ADDR) message['Date'] = formatdate(localtime=True) message['Subject'] = subject smtp = smtplib.SMTP(SMTP_SERVER) smtp.sendmail(message['From'], TO_ADDR, message.as_string()) ### disk management DEFAULT_LAST_USED_NAME = 'Last used backup volume name' DEFAULT_LAST_USED_DATE = 'Date of last backup volume use' defaults = NSUserDefaults.standardUserDefaults() diskManager = DMManager.sharedManager() diskManager.generateConnection() def diskWithName(name): disks = diskManager.disks() for disk in disks: if disk.volumeName() == name: return disk def lastUsedDisk(): diskName = defaults.get(DEFAULT_LAST_USED_NAME) if diskName: return diskWithName(diskName) def diskIsMounted(disk): mountPoint = disk.mountPoint() return (mountPoint and os.path.exists(mountPoint)) def ejectDisk(disk): if not diskIsMounted(disk): return True if not diskManager.ejectDisk_synchronous_(disk, True): return False if diskIsMounted(disk): growlNotify(NOTIFICATION_MEDIA, 'Eject failed', 'Using diskutil to eject %s' % disk) return call('diskutil', 'eject', disk.diskIdentifier()) is 0 return True def mountDisk(disk): if diskIsMounted(disk): return True if not diskManager.mountDisk_includeChildren_synchronous_(disk, False, True): return False if not diskIsMounted(disk): growlNotify(NOTIFICATION_MEDIA, 'Mount failed', 'Using diskutil to mount %s' % disk) return call('diskutil', 'mount', disk.diskIdentifier()) is 0 return True ### Retrospect event handlers def retrospectstart(autolaunchboolean): try: app(id='com.apple.Backup').quit(timeout=5) except CommandError: addEvent('Apple Backup failed to quit', 'Please check on it.') def scriptstart(scriptname, startdate): pass # yes, this really should be "remotetname" def volumestart(volumename, clientname='', remotetname=''): pass def volumeend(volumename, kbcopied, filecount, durationinseconds, backupdate, enddate, startdate, destinationname, clientname, scriptname, backuptypestring, fileerrorcount, volumeerrorcode, volumeerrormessage, zonename='', remotename='', subvolumediskname=''): growlNotify(NOTIFICATION_BACKUP, scriptname, '%s\n%s' % (volumename, volumeerrormessage)) addEvent('%s: %s' % (volumename, volumeerrormessage), '%d file(s) copied; %d had errors.' % (filecount, fileerrorcount)) def mediarequest(mediatypestring, requestedmembername, mediaisknownboolean): disk = diskWithName(requestedmembername) if disk and mountDisk(disk): defaults[DEFAULT_LAST_USED_NAME] = requestedmembername defaults[DEFAULT_LAST_USED_DATE] = NSDate.date() growlNotify(NOTIFICATION_MEDIA, requestedmembername, 'Disk is in use: please DO NOT swap') else: sendMail("IMPORTANT: Can't find disk %s" % requestedmembername, "Retrospect can't find the disk '%s'. Please eject the old disk, if any, and check that the correct disk is inserted." % requestedmembername) def mediarequesttimedout(numberofsecondswaited): pass def scriptend(scriptname, scripterrormessage, errorcount): disk = lastUsedDisk() if disk and ejectDisk(disk): growlNotify(NOTIFICATION_MEDIA, disk.volumeName(), 'Disk no longer in use: OK to swap') addEvent(scriptname + ': script completed', '%s; %s error(s)' % (scripterrormessage, errorcount)) def scriptcheckfailed(scriptname, failuremessage, scheduledate): failuremessage = failuremessage.replace('\r', '\n').decode('macroman') m = re.search(u'\u201c([^\u201d]+)\u201d', failuremessage) if not m: addEvent(scriptname + ': check failed', failuremessage) return if not diskWithName(m.group(1)): sendMail("Please swap backup media before %s" % scheduledate.ctime(), failuremessage + "\n\nEject the old disk, if any, and check that the correct disk is inserted.") def retrospectquit(): # can use launch() in newer appscript versions without CommandError app(id='com.apple.Backup').activate() stopeventloop() def handleEvent(eventName, params): eventHandler = globals().get(eventName) params = dict(zip(params[::2], params[1::2])) for k in params.keys(): if params[k] == '': del params[k] eventStr = '%s(%s)' % (eventName, ', '.join(['%s=%r' % (k, v) for k, v in params.iteritems()])) if not eventHandler: addEvent('%s: unhandled event' % eventName, eventStr) if DEBUG: NSLog('unhandled event: ' + eventStr) return try: eventHandler(**params) except: s = StringIO() print_exc(file=s) addEvent('%s: event handler failed' % eventName, '%s\n\n%s' % (eventStr, s.getvalue()), True) if DEBUG: NSLog('event handler failed for %s:\n%s' % (eventStr, s.getvalue())) installeventhandler(handleEvent, 'ascrpsbr', ('snam', 'eventName', kAE.typeUnicodeText), ('usrf', 'params', ArgListOf(kAE.typeWildCard))) if __name__ == '__main__': growl.register( as_application=GROWL_APP_NAME, all_notifications=NOTIFICATIONS_ALL, default_notifications=NOTIFICATIONS_ALL) starteventloop() # example events: # retrospectstart(autolaunchboolean=False) # scriptstart(scriptname='Boston Backup', startdate=datetime.datetime(2005, 11, 23, 2, 31, 51)) # volumestart(volumename='Backup Clients:Babs') # volumeend(startdate=datetime.datetime(2005, 11, 23, 2, 31, 51), kbcopied=0, enddate=datetime.datetime(2005, 11, 23, 2, 32, 5), backuptypestring='Normal', volumename='Backup Clients:Babs', clientname='Babs', destinationname='Boston backup A', scriptname='Boston Backup', volumeerrormessage='client is not visible on network', backupdate=datetime.datetime(2005, 11, 23, 2, 32, 5), durationinseconds=0, volumeerrorcode=-1028, fileerrorcount=0, remotename='Babs', filecount=0, zonename='10.0.0.9') # volumestart(volumename=' Local Desktop:Bookworm') # volumestart(volumename='Backup Clients:Concord:Mac OS X', remotetname='Backup Clients:Concord', clientname='Backup Clients:Concord') # mediarequest: appears even if media is available # mediarequest(mediatypestring='disk', requestedmembername='1-Boston backup A', mediaisknownboolean=True) # mediarequest(mediatypestring='disk', requestedmembername='1-Boston backup A', mediaisknownboolean=True) # volumeend(startdate=datetime.datetime(2005, 11, 23, 2, 31, 51), kbcopied=2291359, enddate=datetime.datetime(2005, 11, 23, 2, 56, 23), backuptypestring='Normal', volumename=' Local Desktop:Bookworm', clientname='Local Desktop', destinationname='Boston backup A', scriptname='Boston Backup', volumeerrormessage='successful', backupdate=datetime.datetime(2005, 11, 23, 2, 31, 51), durationinseconds=1066, volumeerrorcode=0, fileerrorcount=15, remotename='Local Desktop', filecount=72717) # scriptend(scriptname='Boston Backup', scripterrormessage='client is not visible on network', errorcount=18) # scriptcheckfailed(scriptname='Boston Backup', failuremessage='Appending to disk \xd21-Boston backup B\xd3\r\t149.0 G available (80%) of 186.2 G total capacity', scheduledate=datetime.datetime(2005, 11, 24, 1, 0)) # retrospectquit()