#!/usr/bin/python2.6
#
# CDDL HEADER START
#
# The contents of this file are subject to the terms of the
# Common Development and Distribution License (the "License").
# You may not use this file except in compliance with the License.
#
# You can obtain a copy of the license at usr/src/OPENSOLARIS.LICENSE
# or http://www.opensolaris.org/os/licensing.
# See the License for the specific language governing permissions
# and limitations under the License.
#
# When distributing Covered Code, include this CDDL HEADER in each
# file and include the License file at usr/src/OPENSOLARIS.LICENSE.
# If applicable, add the following below this CDDL HEADER, with the
# fields enclosed by brackets "[]" replaced with your own identifying
# information: Portions Copyright [yyyy] [name of copyright owner]
#
# CDDL HEADER END
#
import sys
import os
import subprocess
import threading
import gobject
import dbus
import dbus.decorators
import dbus.glib
import dbus.mainloop
import dbus.mainloop.glib
import gio
import gtk
import pygtk
import pynotify
from time_slider import util, rbac
from os.path import abspath, dirname, join, pardir
sys.path.insert(0, join(dirname(__file__), pardir, "plugin"))
import plugin
sys.path.insert(0, join(dirname(__file__), pardir, "plugin", "rsync"))
import backup, rsyncsmf
class Note:
_iconConnected = False
def __init__(self, icon, menu):
self._note = None
self._msgDialog = None
self._menu = menu
self._icon = icon
if Note._iconConnected == False:
self._icon.connect("popup-menu", self._popup_menu)
Note._iconConnected = True
self._icon.set_visible(True)
def _popup_menu(self, icon, button, time):
if button == 3:
# Don't popup an empty menu
if len(self._menu.get_children()) > 0:
self._menu.popup(None, None,
gtk.status_icon_position_menu,
button, time, icon)
def _dialog_response(self, dialog, response):
dialog.destroy()
def _notification_closed(self, notifcation):
self._note = None
self._icon.set_blinking(False)
def _show_notification(self):
if self._icon.is_embedded() == True:
self._note.attach_to_status_icon(self._icon)
self._note.show()
return False
def _connect_to_object(self):
pass
def refresh(self):
pass
def _watch_handler(self, new_owner = None):
if new_owner == None or len(new_owner) == 0:
pass
else:
self._connect_to_object()
def _setup_icon_for_note(self, themed=None):
if themed:
iconList = themed.get_names()
else:
iconList = ['gnome-dev-harddisk']
iconTheme = gtk.icon_theme_get_default()
iconInfo = iconTheme.choose_icon(iconList, 48, 0)
pixbuf = iconInfo.load_icon()
self._note.set_category("device")
self._note.set_icon_from_pixbuf(pixbuf)
class RsyncNote(Note):
def __init__(self, icon, menu):
Note.__init__(self, icon, menu)
dbus.bus.NameOwnerWatch(bus,
"org.opensolaris.TimeSlider.plugin.rsync",
self._watch_handler)
self.smfInst = rsyncsmf.RsyncSMF("%s:rsync" \
% (plugin.PLUGINBASEFMRI))
self._lock = threading.Lock()
self._masterKey = None
sys,self._nodeName,rel,ver,arch = os.uname()
# References to gio.File and handler_id of a registered
# monitor callback on gio.File
self._fm = None
self._fmID = None
# References to gio.VolumeMonitor and handler_ids of
# registered mount-added and mount-removed callbacks.
self._vm = None
self._vmAdd = None
self._vmRem = None
# Every time the rsync backup script runs it will
# register with d-bus and trigger self._watch_handler().
# Use this variable to keep track of it's running status.
self._scriptRunning = False
self._targetDirAvail = False
self._syncNowItem = gtk.MenuItem(_("Update Backups Now"))
self._syncNowItem.set_sensitive(False)
self._syncNowItem.connect("activate",
self._sync_now)
self._menu.append(self._syncNowItem)
self.refresh()
def _validate_rsync_target(self, path):
"""
Tests path to see if it is the pre-configured
rsync backup device path.
Returns True on success, otherwise False
"""
if not os.path.exists(path):
return False
testDir = join(path,
rsyncsmf.RSYNCDIRPREFIX,
self._nodeName)
testKeyFile = join(path,
rsyncsmf.RSYNCDIRPREFIX,
rsyncsmf.RSYNCCONFIGFILE)
if os.path.exists(testDir) and \
os.path.exists(testKeyFile):
testKeyVal = None
f = open(testKeyFile, 'r')
for line in f.readlines():
key, val = line.strip().split('=')
if key.strip() == "target_key":
targetKey = val.strip()
break
f.close()
if targetKey == self._masterKey:
return True
return False
def _setup_monitor(self):
# Disconnect any previously registered signal
# handlers
if self._fm:
self._fm.disconnect(self._fmID)
self._fm = None
useVolMonitor = False
# We always compare against masterKey to validate
# an rsync backup device.
self._masterKey = self.smfInst.get_target_key()
self._baseTargetDir = None
online = False
self._masterTargetDir = self.smfInst.get_target_dir()
if self._validate_rsync_target(self._masterTargetDir) == True:
self._baseTargetDir = self._masterTargetDir
online = True
if self._vm == None:
self._vm = gio.volume_monitor_get()
# If located, see if it's also managed by the volume monitor.
# Or just try to find it otherwise.
mounts = self._vm.get_mounts()
for mount in mounts:
root = mount.get_root()
path = root.get_path()
if self._baseTargetDir != None and \
path == self._baseTargetDir:
# Means the directory we found is gio monitored,
# so just monitor it using gio.VolumeMonitor.
useVolMonitor = True
break
elif self._validate_rsync_target(path) == True:
# Found it but not where we expected it to be so override
# the target path defined by SMF for now.
useVolMonitor = True
self._baseTargetDir = path
online = True
break
if self._baseTargetDir == None:
# Means we didn't find it, and we don't know where to expect
# it either - via a hotpluggable device or other nfs/zfs etc.
# We need to hedge our bets and monitor for both.
self._setup_file_monitor(self._masterTargetDir)
self._setup_volume_monitor()
else:
# Found it
if useVolMonitor == True:
# Looks like a removable device. Use gio.VolumeMonitor
# as the preferred monitoring mechanism.
self._setup_volume_monitor()
else:
# Found it on a static mount point like a zfs or nfs
# mount point.
# Can't use gio.VolumeMonitor so use a gio.File monitor
# instead.
self._setup_file_monitor(self._masterTargetDir)
# Finally, update the UI menu state
self._lock.acquire()
self._targetDirAvail = online
self._update_menu_state()
self._lock.release()
def _setup_file_monitor(self, expectedPath):
# Use gio.File monitor as a fallback in
# case gio.VolumeMonitor can't track the device.
# This is the case for static/manual mount points
# such as NFS, ZFS and other non-hotpluggables.
gFile = gio.File(path=expectedPath)
self._fm = gFile.monitor_file(gio.FILE_MONITOR_WATCH_MOUNTS)
self._fmID = self._fm.connect("changed",
self._file_monitor_changed)
def _setup_volume_monitor(self):
# Check the handler_ids first to see if they have
# already been connected. Avoids multiple callbacks
# for a single event
if self._vmAdd == None:
self._vmAdd = self._vm.connect("mount-added",
self._mount_added)
if self._vmRem == None:
self._vmRem = self._vm.connect("mount-removed",
self._mount_removed)
def _mount_added(self, monitor, mount):
root = mount.get_root()
path = root.get_path()
if self._validate_rsync_target(path) == True:
# Since gio.VolumeMonitor found the rsync target, don't
# bother relying on gio.File to find it any more. Disconnect
# it's registered callbacks.
if self._fm:
self._fm.disconnect(self._fmID)
self._fm = None
self._lock.acquire()
self._baseTargetDir = path
self._targetDirAvail = True
self._update_menu_state()
self._lock.release()
def _mount_removed(self, monitor, mount):
root = mount.get_root()
path = root.get_path()
if path == self._baseTargetDir:
self._lock.acquire()
self._targetDirAvail = False
self._update_menu_state()
self._lock.release()
def _file_monitor_changed(self, filemonitor, file, other_file, event_type):
if file.get_path() == self._masterTargetDir:
self._lock.acquire()
if self._validate_rsync_target(self._masterTargetDir) == True:
self._targetDirAvail = True
else:
self._targetDirAvail = False
self._update_menu_state()
self._lock.release()
def _update_menu_state(self):
if self._syncNowItem:
if self._targetDirAvail == True and \
self._scriptRunning == False:
self._syncNowItem.set_sensitive(True)
else:
self._syncNowItem.set_sensitive(False)
def _watch_handler(self, new_owner = None):
self._lock.acquire()
if new_owner == None or len(new_owner) == 0:
# Script not running or exited
self._scriptRunning = False
else:
self._scriptRunning = True
self._connect_to_object()
self._update_menu_state()
self._lock.release()
def _rsync_started_handler(self, target, sender=None, interface=None, path=None):
urgency = pynotify.URGENCY_NORMAL
if (self._note != None):
self._note.close()
# Try to pretty things up a bit by displaying volume name
# and hinted icon instead of the raw device path,
# and standard harddisk icon if possible.
icon = None
volume = util.path_to_volume(target)
if volume == None:
volName = target
else:
volName = volume.get_name()
icon = volume.get_icon()
self._note = pynotify.Notification(_("Backup Started"),
_("Backing up snapshots to:\n%s\n" \
"Do not disconnect the backup device.") \
% (volName))
self._note.connect("closed", \
self._notification_closed)
self._note.set_urgency(urgency)
self._setup_icon_for_note(icon)
gobject.idle_add(self._show_notification)
def _rsync_current_handler(self, snapshot, remaining, sender=None, interface=None, path=None):
self._icon.set_tooltip_markup(_("Backing up: \'%s\'\n%d snapshots remaining.\n" \
"Do not disconnect the backup device.") \
% (snapshot, remaining))
def _rsync_complete_handler(self, target, sender=None, interface=None, path=None):
urgency = pynotify.URGENCY_NORMAL
if (self._note != None):
self._note.close()
# Try to pretty things up a bit by displaying volume name
# and hinted icon instead of the raw device path,
# and standard harddisk icon if possible.
icon = None
volume = util.path_to_volume(target)
if volume == None:
volName = target
else:
volName = volume.get_name()
icon = volume.get_icon()
self._note = pynotify.Notification(_("Backup Complete"),
_("Your snapshots have been backed up to:\n%s") \
% (volName))
self._note.connect("closed", \
self._notification_closed)
self._note.set_urgency(urgency)
self._setup_icon_for_note(icon)
self._icon.set_has_tooltip(False)
self.queueSize = 0
gobject.idle_add(self._show_notification)
def _rsync_synced_handler(self, sender=None, interface=None, path=None):
self._icon.set_tooltip_markup(_("Your backups are up to date."))
self.queueSize = 0
def _rsync_unsynced_handler(self, queueSize, sender=None, interface=None, path=None):
self._icon.set_tooltip_markup(_("%d snapshots are queued for backup.") \
% (queueSize))
self.queueSize = queueSize
def _connect_to_object(self):
try:
remote_object = bus.get_object("org.opensolaris.TimeSlider.plugin.rsync",
"/org/opensolaris/TimeSlider/plugin/rsync")
except dbus.DBusException:
sys.stderr.write("Failed to connect to remote D-Bus object: " + \
"/org/opensolaris/TimeSlider/plugin/rsync")
return
# Create an Interface wrapper for the remote object
iface = dbus.Interface(remote_object, "org.opensolaris.TimeSlider.plugin.rsync")
iface.connect_to_signal("rsync_started", self._rsync_started_handler, sender_keyword='sender',
interface_keyword='interface', path_keyword='path')
iface.connect_to_signal("rsync_current", self._rsync_current_handler, sender_keyword='sender',
interface_keyword='interface', path_keyword='path')
iface.connect_to_signal("rsync_complete", self._rsync_complete_handler, sender_keyword='sender',
interface_keyword='interface', path_keyword='path')
iface.connect_to_signal("rsync_synced", self._rsync_synced_handler, sender_keyword='sender',
interface_keyword='interface', path_keyword='path')
iface.connect_to_signal("rsync_unsynced", self._rsync_unsynced_handler, sender_keyword='sender',
interface_keyword='interface', path_keyword='path')
def refresh(self):
# Hide/Unhide rsync menu item based on whether the plugin is online
if self._syncNowItem and \
self.smfInst.get_service_state() == "online":
#self._setup_file_monitor()
self._setup_monitor()
# Kick start things by initially obtaining the
# backlog size and triggering a callback.
# Signal handlers will keep tooltip status up
# to date afterwards when the backup cron job
# executes.
propName = "%s:rsync" % (backup.propbasename)
queue = backup.list_pending_snapshots(propName)
self.queueSize = len(queue)
if self.queueSize == 0:
self._rsync_synced_handler()
else:
self._rsync_unsynced_handler(self.queueSize)
self._syncNowItem.show()
else:
self._syncNowItem.hide()
def _sync_now(self, menuItem):
"""Runs the rsync-backup script manually
Assumes that user is root since it is only
called from the menu item which is invisible to
not authorised users
"""
cmdPath = os.path.join(os.path.dirname(sys.argv[0]), \
"time-slider/plugins/rsync/rsync-backup")
if os.geteuid() == 0:
cmd = [cmdPath, \
"%s:rsync" % (plugin.PLUGINBASEFMRI)]
else:
cmd = ['/usr/bin/gksu' ,cmdPath, \
"%s:rsync" % (plugin.PLUGINBASEFMRI)]
subprocess.Popen(cmd, close_fds=True, cwd="/")
class CleanupNote(Note):
def __init__(self, icon, menu):
Note.__init__(self, icon, menu)
self._cleanupHead = None
self._cleanupBody = None
dbus.bus.NameOwnerWatch(bus,
"org.opensolaris.TimeSlider",
self._watch_handler)
def _show_cleanup_details(self, *args):
# We could keep a dialog around but this a rare
# enough event that's it not worth the effort.
dialog = gtk.MessageDialog(type=gtk.MESSAGE_WARNING,
buttons=gtk.BUTTONS_CLOSE)
dialog.set_title(_("Time Slider: Low Space Warning"))
dialog.set_markup("%s" % (self._cleanupHead))
dialog.format_secondary_markup(self._cleanupBody)
dialog.show()
dialog.present()
dialog.connect("response", self._dialog_response)
def _cleanup_handler(self, pool, severity, threshhold, sender=None, interface=None, path=None):
if severity == 4:
expiry = pynotify.EXPIRES_NEVER
urgency = pynotify.URGENCY_CRITICAL
self._cleanupHead = _("Emergency: \'%s\' is full!") % pool
notifyBody = _("The file system: \'%s\', is over %s%% full.") \
% (pool, threshhold)
self._cleanupBody = _("The file system: \'%s\', is over %s%% full.\n"
"As an emergency measure, Time Slider has "
"destroyed all of its backups.\nTo fix this problem, "
"delete any unnecessary files on \'%s\', or add "
"disk space (see ZFS documentation).") \
% (pool, threshhold, pool)
elif severity == 3:
expiry = pynotify.EXPIRES_NEVER
urgency = pynotify.URGENCY_CRITICAL
self._cleanupHead = _("Emergency: \'%s\' is almost full!") % pool
notifyBody = _("The file system: \'%s\', exceeded %s%% "
"of its total capacity") \
% (pool, threshhold)
self._cleanupBody = _("The file system: \'%s\', exceeded %s%% "
"of its total capacity. As an emerency measure, "
"Time Slider has has destroyed most or all of its "
"backups to prevent the disk becoming full. "
"To prevent this from happening again, delete "
"any unnecessary files on \'%s\', or add disk "
"space (see ZFS documentation).") \
% (pool, threshhold, pool)
elif severity == 2:
expiry = pynotify.EXPIRES_NEVER
urgency = pynotify.URGENCY_CRITICAL
self._cleanupHead = _("Urgent: \'%s\' is almost full!") % pool
notifyBody = _("The file system: \'%s\', exceeded %s%% "
"of its total capacity") \
% (pool, threshhold)
self._cleanupBody = _("The file system: \'%s\', exceeded %s%% "
"of its total capacity. As a remedial measure, "
"Time Slider has destroyed some backups, and will "
"destroy more, eventually all, as capacity continues "
"to diminish.\nTo prevent this from happening again, "
"delete any unnecessary files on \'%s\', or add disk "
"space (see ZFS documentation).") \
% (pool, threshhold, pool)
elif severity == 1:
expiry = 20000 # 20 seconds
urgency = pynotify.URGENCY_NORMAL
self._cleanupHead = _("Warning: \'%s\' is getting full") % pool
notifyBody = _("The file system: \'%s\', exceeded %s%% "
"of its total capacity") \
% (pool, threshhold)
self._cleanupBody = _("\'%s\' exceeded %s%% of its total "
"capacity. To fix this, Time Slider has destroyed "
"some recent backups, and will destroy more as "
"capacity continues to diminish.\nTo prevent "
"this from happening again, delete any "
"unnecessary files on \'%s\', or add disk space "
"(see ZFS documentation).\n") \
% (pool, threshhold, pool)
else:
return # No other values currently supported
if (self._note != None):
self._note.close()
self._note = pynotify.Notification(self._cleanupHead,
notifyBody)
self._note.add_action("clicked",
_("Details..."),
self._show_cleanup_details)
self._note.connect("closed",
self._notification_closed)
self._note.set_urgency(urgency)
self._note.set_timeout(expiry)
self._setup_icon_for_note()
self._icon.set_blinking(True)
gobject.idle_add(self._show_notification)
def _connect_to_object(self):
try:
remote_object = bus.get_object("org.opensolaris.TimeSlider",
"/org/opensolaris/TimeSlider/autosnap")
except dbus.DBusException:
sys.stderr.write("Failed to connect to remote D-Bus object: " + \
"/org/opensolaris/TimeSlider/autosnap")
#Create an Interface wrapper for the remote object
iface = dbus.Interface(remote_object, "org.opensolaris.TimeSlider.autosnap")
iface.connect_to_signal("capacity_exceeded", self._cleanup_handler, sender_keyword='sender',
interface_keyword='interface', path_keyword='path')
class SetupNote(Note):
def __init__(self, icon, menu, manager):
Note.__init__(self, icon, menu)
# We are passed a reference to out parent so we can
# provide it notification which it can then circulate
# to other notification objects such as Rsync and
# Cleanup
self._manager = manager
self._icon = icon
self._menu = menu
self._configSvcItem = gtk.MenuItem(_("Configure Time Slider..."))
self._configSvcItem.connect("activate",
self._run_config_app)
self._configSvcItem.set_sensitive(True)
self._menu.append(self._configSvcItem)
self._configSvcItem.show()
dbus.bus.NameOwnerWatch(bus,
"org.opensolaris.TimeSlider.config",
self._watch_handler)
def _connect_to_object(self):
try:
remote_object = bus.get_object("org.opensolaris.TimeSlider.config",
"/org/opensolaris/TimeSlider/config")
except dbus.DBusException:
sys.stderr.write("Failed to connect to remote D-Bus object: " + \
"/org/opensolaris/TimeSlider/config")
#Create an Interface wrapper for the remote object
iface = dbus.Interface(remote_object, "org.opensolaris.TimeSlider.config")
iface.connect_to_signal("config_changed", self._config_handler, sender_keyword='sender',
interface_keyword='interface', path_keyword='path')
def _config_handler(self, sender=None, interface=None, path=None):
# Notify the manager.
# This will eventually propogate through to an invocation
# of our own refresh() method.
self._manager.refresh()
def _run_config_app(self, menuItem):
cmdPath = os.path.join(os.path.dirname(sys.argv[0]),
os.path.pardir,
"bin",
"time-slider-setup")
cmd = os.path.abspath(cmdPath)
# The setup GUI deals with it's own security and
# authorisation, so no need to pfexec it. Any
# changes made to configuration will come back to
# us by way of D-Bus notification.
subprocess.Popen(cmd, close_fds=True)
class NoteManager():
def __init__(self):
# Notification objects need to share a common
# status icon and popup menu so these are created
# outside the object and passed to the constructor
self._menu = gtk.Menu()
self._icon = gtk.StatusIcon()
self._icon.set_from_icon_name("time-slider-setup")
self._setupNote = SetupNote(self._icon,
self._menu,
self)
self._cleanupNote = CleanupNote(self._icon,
self._menu)
self._rsyncNote = RsyncNote(self._icon,
self._menu)
def refresh(self):
self._rsyncNote.refresh()
bus = dbus.SystemBus()
def main(argv):
mainloop = gobject.MainLoop()
dbus.mainloop.glib.DBusGMainLoop(set_as_default = True)
gobject.threads_init()
pynotify.init(_("Time Slider"))
noteManager = NoteManager()
try:
mainloop.run()
except:
print "Exiting"
if __name__ == '__main__':
main()