source: trunk/RetroStatus/RetroStatus.py @ 204

Last change on this file since 204 was 204, checked in by Nicholas Riley, 15 years ago

formatflowed.py: Removed; no longer used.

RetroStatus?.py: Remove format=flowed support, it doesn't work nicely
with UTF-8 encoded email. Fix sendMail bugs; it works now. Handle
volumestart event on clients (parameter is misspelled). Standardize
RSS titles to be of the form "name: status". Fixed scriptcheckfailed
to pull out the requested disk and actually check for it (mounted or
not). Add an example of the aformentioned volumestart event variant.
Fixed repeated typo of StringIO.getvalue in event exception handler.

File size: 10.0 KB
Line 
1#!/usr/bin/pythonw
2
3from appscript import app, k
4from aemreceive.sfba import *
5from Foundation import NSLog, NSUserDefaults, NSDate
6from subprocess import call
7from traceback import print_exc
8from cStringIO import StringIO
9from datetime import datetime
10import RSS
11import email, smtplib
12from email.MIMEMessage import MIMEMessage
13from email.MIMEText import MIMEText
14from email.Utils import formataddr, formatdate
15import os, re, sys
16import objc # XXX should switch to public DADiskMount/Eject functions
17objc.loadBundle('DiskManagement', globals(),
18                '/System/Library/PrivateFrameworks/DiskManagement.framework')
19
20EVENTS_DIR = os.path.expanduser('~/Sites/RetroStatus')
21EVENTS_MAX = 100
22
23FROM_NAME, FROM_ADDR = 'Retrospect backup server', 'retrospect' + '@rileys.us'
24TO_ADDR = ['%s@rileys.us' % user for user in ['julia', 'gary']]
25SMTP_SERVER = 'calamity.bos.sabi.net'
26
27DEBUG = True
28
29# XXX implement Web page with current status
30
31### notifications
32
33GROWL_APP_NAME = 'RetroStatus'
34NOTIFICATION_BACKUP = 'Backup'
35NOTIFICATION_MEDIA = 'Media change'
36NOTIFICATIONS_ALL = [NOTIFICATION_BACKUP, NOTIFICATION_MEDIA]
37EVENTS_FILE = os.path.join(EVENTS_DIR, 'index.rdf')
38EVENTS_FILE_TEMP = EVENTS_FILE + '~'
39
40growl = app('GrowlHelperApp')
41events = RSS.TrackingChannel()
42
43try: os.mkdir(EVENTS_DIR)
44except: pass
45events.setMD((RSS.ns.rss10, 'channel'),
46             {(RSS.ns.rss10, 'title'): 'Retrospect status',
47              (RSS.ns.rss10, 'link'): os.path.basename(EVENTS_FILE)})
48
49def growlNotify(name, title, description, **kw):
50    # XXX restore coalescing support from LocationDo
51    params = dict(with_name=name,
52                  title=title,
53                  description=unicode(description),
54                  application_name=GROWL_APP_NAME,
55                  icon_of_application='Retrospect')
56    params.update(kw)
57    if DEBUG:
58        NSLog('%s: %s' % (title, description))
59    growl.notify(**params)
60
61def addEvent(title, description, preformatted=False):
62    description = description.replace('&', '&amp;').replace('<', '&lt;')
63    if preformatted:
64        description = '<pre>%s</pre>' % description
65    else:
66        description = description.replace('\n\n', '<p>').replace('\n', '<br>')
67    # XXX provide valid URL for event details
68    events.addItem({(RSS.ns.dc, 'date'): datetime.today().isoformat(),
69                    (RSS.ns.rss10, 'title'): title,
70                    (RSS.ns.rss10, 'link'): datetime.today().isoformat(),
71                    (RSS.ns.rss10, 'description'): description})
72    events.truncateToLength(EVENTS_MAX)
73    file(EVENTS_FILE_TEMP, 'w').write(str(events))
74    os.rename(EVENTS_FILE_TEMP, EVENTS_FILE)
75
76def sendMail(subject, text):
77    message = MIMEText(text.encode('utf8'), _charset='utf8')
78    message.set_unixfrom(FROM_ADDR)
79    message['From'] = formataddr((FROM_NAME, FROM_ADDR))
80    message['To'] = ', '.join(TO_ADDR)
81    message['Date'] = formatdate(localtime=True)
82    message['Subject'] = subject
83    smtp = smtplib.SMTP(SMTP_SERVER)
84    smtp.sendmail(message['From'], TO_ADDR, message.as_string())
85
86### disk management
87
88DEFAULT_LAST_USED_NAME = 'Last used backup volume name'
89DEFAULT_LAST_USED_DATE = 'Date of last backup volume use'
90
91defaults = NSUserDefaults.standardUserDefaults()
92diskManager = DMManager.sharedManager()
93diskManager.generateConnection()
94
95def diskWithName(name):
96    disks = diskManager.disks()
97    for disk in disks:
98        if disk.volumeName() == name:
99            return disk
100
101def lastUsedDisk():
102    diskName = defaults.get(DEFAULT_LAST_USED_NAME)
103    if diskName:
104        return diskWithName(diskName)
105
106def diskIsMounted(disk):
107    mountPoint = disk.mountPoint()
108    return (mountPoint and os.path.exists(mountPoint))
109
110def ejectDisk(disk):
111    if not diskIsMounted(disk):
112        return True
113    if not diskManager.ejectDisk_synchronous_(disk, True):
114        return False
115    if diskIsMounted(disk):
116        growlNotify(NOTIFICATION_MEDIA, 'Eject failed',
117                    'Using diskutil to eject %s' % disk)
118        return call('diskutil', 'eject', disk.diskIdentifier()) is 0
119    return True
120
121def mountDisk(disk):
122    if diskIsMounted(disk):
123        return True
124    if not diskManager.mountDisk_includeChildren_synchronous_(disk, False, True):
125        return False
126    if not diskIsMounted(disk):
127        growlNotify(NOTIFICATION_MEDIA, 'Mount failed',
128                    'Using diskutil to mount %s' % disk)
129        return call('diskutil', 'mount', disk.diskIdentifier()) is 0
130    return True
131
132### Retrospect event handlers
133
134def retrospectstart(autolaunchboolean): pass
135def scriptstart(scriptname, startdate): pass
136# yes, this really should be "remotetname"
137def volumestart(volumename, clientname='', remotetname=''): pass
138def volumeend(volumename, kbcopied, filecount, durationinseconds, backupdate,
139              enddate, startdate, destinationname, clientname, scriptname,
140              backuptypestring, fileerrorcount, volumeerrorcode,
141              volumeerrormessage, zonename='', remotename='',
142              subvolumediskname=''):
143    growlNotify(NOTIFICATION_BACKUP, scriptname,
144                '%s\n%s' % (volumename, volumeerrormessage))
145    addEvent('%s: %s' % (volumename, volumeerrormessage),
146             '%d file(s) copied; %d had errors.' % (filecount, fileerrorcount))
147
148def mediarequest(mediatypestring, requestedmembername, mediaisknownboolean):
149    disk = diskWithName(requestedmembername)
150    if disk and mountDisk(disk):
151            defaults[DEFAULT_LAST_USED_NAME] = requestedmembername
152            defaults[DEFAULT_LAST_USED_DATE] = NSDate.date()
153            growlNotify(NOTIFICATION_MEDIA, requestedmembername,
154                        'Disk is in use: please DO NOT swap')
155    else:
156        sendMail("IMPORTANT: Can't find disk %s" % requestedmembername,
157                 "Retrospect can't find the disk '%s'.  Please eject the old disk, if any, and check that the correct disk is inserted.")
158
159def mediarequesttimedout(numberofsecondswaited): pass
160
161def scriptend(scriptname, scripterrormessage, errorcount):
162    disk = lastUsedDisk()
163    if disk and ejectDisk(disk):
164        growlNotify(NOTIFICATION_MEDIA, disk.volumeName(),
165                    'Disk no longer in use: OK to swap')
166    addEvent(scriptname + ': script completed',
167             '%s; %s error(s)' % (scripterrormessage, errorcount))
168
169# XXX does this get sent when it's supposed to?
170# XXX if not, we could ask for quit on script end if Retrospect was started
171# XXX by a script, and lengthen the lookahead interval
172def scriptcheckfailed(scriptname, failuremessage, scheduledate):
173    failuremessage = failuremessage.replace('\r', '\n').decode('macroman')
174    m = re.search(u'\u201c([^\u201d]+)\u201d', failuremessage)
175    if not m:
176        addEvent(scriptname + ': check failed', failuremessage)
177        return
178    if not diskWithName(m.group(1)):
179        sendMail("Please swap backup media before %s" % scheduledate.ctime(),
180                 failuremessage + "\n\nEject the old disk, if any, and check that the correct disk is inserted.")
181
182def retrospectquit():
183    stopeventloop()
184
185def handleEvent(eventName, params):
186    eventHandler = globals().get(eventName)
187    params = dict(zip(params[::2], params[1::2]))
188    for k in params.keys():
189        if params[k] == '': del params[k]
190    eventStr = '%s(%s)' % (eventName, ', '.join(['%s=%r' % (k, v) for k, v
191                                                 in params.iteritems()]))
192    if not eventHandler:
193        addEvent('%s: unhandled event' % eventName, eventStr)
194        if DEBUG: NSLog('unhandled event: ' + eventStr)
195        return
196    try:
197        eventHandler(**params)
198    except:
199        s = StringIO()
200        print_exc(file=s)
201        addEvent('%s: event handler failed' % eventName,
202                 '%s\n\n%s' % (eventStr, s.getvalue()), True)
203        if DEBUG:
204            NSLog('event handler failed for %s:\n%s' % (eventStr, s.getvalue()))
205
206installeventhandler(handleEvent, 'ascrpsbr',
207                    ('snam', 'eventName', kAE.typeUnicodeText),
208                    ('usrf', 'params', ArgListOf(kAE.typeWildCard)))
209
210if __name__ == '__main__':
211    growl.register(
212        as_application=GROWL_APP_NAME,
213        all_notifications=NOTIFICATIONS_ALL,
214        default_notifications=NOTIFICATIONS_ALL)
215
216    starteventloop()
217
218# example events:
219
220# retrospectstart(autolaunchboolean=False)
221# scriptstart(scriptname='Boston Backup', startdate=datetime.datetime(2005, 11, 23, 2, 31, 51))
222# volumestart(volumename='Backup Clients:Babs')
223
224# 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')
225# volumestart(volumename=' Local Desktop:Bookworm')
226# volumestart(volumename='Backup Clients:Concord:Mac OS X', remotetname='Backup Clients:Concord', clientname='Backup Clients:Concord')
227# mediarequest: appears even if media is available
228# mediarequest(mediatypestring='disk', requestedmembername='1-Boston backup A', mediaisknownboolean=True)
229# mediarequest(mediatypestring='disk', requestedmembername='1-Boston backup A', mediaisknownboolean=True)
230# 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)
231# scriptend(scriptname='Boston Backup', scripterrormessage='client is not visible on network', errorcount=18)
232# 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))
233# retrospectquit()
Note: See TracBrowser for help on using the repository browser.