+#!/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 util
+import smf
+from autosnapsmf import enable_default_schedules, disable_default_schedules
+
+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 rsyncsmf
+
+try:
+ import pygtk
+ pygtk.require("2.4")
+except:
+ pass
+try:
+ import gtk
+ import gtk.glade
+ gtk.gdk.threads_init()
+except:
+ sys.exit(1)
+
+import glib
+import gobject
+import gio
+import dbus
+import dbus.service
+import dbus.mainloop
+import dbus.mainloop.glib
+import dbussvc
+
+
+# This is the rough guess ratio used for rsync backup device size
+# vs. the total size of the pools it's expected to backup.
+RSYNCTARGETRATIO = 2
+
+# here we define the path constants so that other modules can use it.
+# this allows us to get access to the shared files without having to
+# know the actual location, we just use the location of the current
+# file and use paths relative to that.
+SHARED_FILES = os.path.abspath(os.path.join(os.path.dirname(__file__),
+ os.path.pardir,
+ os.path.pardir))
+LOCALE_PATH = os.path.join('/usr', 'share', 'locale')
+RESOURCE_PATH = os.path.join(SHARED_FILES, 'res')
+
+# the name of the gettext domain. because we have our translation files
+# not in a global folder this doesn't really matter, setting it to the
+# application name is a good idea tough.
+GETTEXT_DOMAIN = 'time-slider'
+
+# set up the glade gettext system and locales
+gtk.glade.bindtextdomain(GETTEXT_DOMAIN, LOCALE_PATH)
+gtk.glade.textdomain(GETTEXT_DOMAIN)
+
+import zfs
+from timeslidersmf import TimeSliderSMF
+from rbac import RBACprofile
+
+
+class FilesystemIntention:
+
+ def __init__(self, name, selected, inherited):
+ self.name = name
+ self.selected = selected
+ self.inherited = inherited
+
+ def __str__(self):
+ return_string = "Filesystem name: " + self.name + \
+ "\n\tSelected: " + str(self.selected) + \
+ "\n\tInherited: " + str(self.inherited)
+ return return_string
+
+ def __eq__(self, other):
+ if self.name != other.name:
+ return False
+ if self.inherited and other.inherited:
+ return True
+ elif not self.inherited and other.inherited:
+ return False
+ if (self.selected == other.selected) and \
+ (self.inherited == other.inherited):
+ return True
+ else:
+ return False
+
+class SetupManager:
+
+ def __init__(self, execpath):
+ self._execPath = execpath
+ self._datasets = zfs.Datasets()
+ self._xml = gtk.glade.XML("%s/../../glade/time-slider-setup.glade" \
+ % (os.path.dirname(__file__)))
+
+ # Tell dbus to use the gobject mainloop for async ops
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+ dbus.mainloop.glib.threads_init()
+
+ # Register a bus name with the system dbus daemon
+ systemBus = dbus.SystemBus()
+ busName = dbus.service.BusName("org.opensolaris.TimeSlider.config",
+ systemBus)
+ self._dbus = dbussvc.Config(systemBus,
+ '/org/opensolaris/TimeSlider/config')
+ # Used later to trigger a D-Bus notification of select configuration
+ # changes made
+ self._configNotify = False
+
+ # These variables record the initial UI state which are used
+ # later to compare against the UI state when the OK or Cancel
+ # button is clicked and apply the minimum set of necessary
+ # configuration changes. Prevents minor changes taking ages
+ # to be applied by the GUI.
+ self._initialEnabledState = None
+ self._initialRsyncState = None
+ self._initialRsyncTargetDir = None
+ self._initialCleanupLevel = None
+ self._initialCustomSelection = False
+ self._initialSnapStateDic = {}
+ self._initialRsyncStateDic = {}
+ self._initialFsIntentDic = {}
+ self._initialRsyncIntentDic = {}
+
+ # Currently selected rsync backup device via the GUI.
+ self._newRsyncTargetDir = None
+ # Used to store GUI filesystem selection state and the
+ # set of intended properties to apply to zfs filesystems.
+ self._snapStateDic = {}
+ self._rsyncStateDic = {}
+ self._fsIntentDic = {}
+ self._rsyncIntentDic = {}
+ # Dictionary that maps device ID numbers to zfs filesystem objects
+ self._fsDevices = {}
+
+ topLevel = self._xml.get_widget("toplevel")
+ self._pulseDialog = self._xml.get_widget("pulsedialog")
+ self._pulseDialog.set_transient_for(topLevel)
+
+ # gio.VolumeMonitor reference
+ self._vm = gio.volume_monitor_get()
+ self._vm.connect("mount-added", self._mount_added)
+ self._vm.connect("mount-removed" , self._mount_removed)
+
+ self._fsListStore = gtk.ListStore(bool,
+ bool,
+ str,
+ str,
+ gobject.TYPE_PYOBJECT)
+ filesystems = self._datasets.list_filesystems()
+ for fsname,fsmountpoint in filesystems:
+ if (fsmountpoint == "legacy"):
+ mountpoint = _("Legacy")
+ else:
+ mountpoint = fsmountpoint
+ fs = zfs.Filesystem(fsname, fsmountpoint)
+ # Note that we don't deal with legacy mountpoints.
+ if fsmountpoint != "legacy" and fs.is_mounted():
+ self._fsDevices[os.stat(fsmountpoint).st_dev] = fs
+ snap = fs.get_auto_snap()
+ rsyncstr = fs.get_user_property(rsyncsmf.RSYNCFSTAG)
+ if rsyncstr == "true":
+ rsync = True
+ else:
+ rsync = False
+ # Rsync is only performed on snapshotted filesystems.
+ # So treat as False if rsync is set to true independently
+ self._fsListStore.append([snap, snap & rsync,
+ mountpoint, fs.name, fs])
+ self._initialSnapStateDic[fs.name] = snap
+ self._initialRsyncStateDic[fs.name] = snap & rsync
+ del filesystems
+
+ for fsname in self._initialSnapStateDic:
+ self._refine_filesys_actions(fsname,
+ self._initialSnapStateDic,
+ self._initialFsIntentDic)
+ self._refine_filesys_actions(fsname,
+ self._initialRsyncStateDic,
+ self._initialRsyncIntentDic)
+
+ self._fsTreeView = self._xml.get_widget("fstreeview")
+ self._fsTreeView.set_sensitive(False)
+ self._fsTreeView.set_size_request(10, 200)
+
+ self._fsTreeView.set_model(self._fsListStore)
+
+ cell0 = gtk.CellRendererToggle()
+ cell1 = gtk.CellRendererToggle()
+ cell2 = gtk.CellRendererText()
+ cell3 = gtk.CellRendererText()
+
+ radioColumn = gtk.TreeViewColumn(_("Select"),
+ cell0, active=0)
+ self._fsTreeView.insert_column(radioColumn, 0)
+
+ self._rsyncRadioColumn = gtk.TreeViewColumn(_("Replicate"),
+ cell1, active=1)
+ nameColumn = gtk.TreeViewColumn(_("Mount Point"),
+ cell2, text=2)
+ self._fsTreeView.insert_column(nameColumn, 2)
+ mountPointColumn = gtk.TreeViewColumn(_("File System Name"),
+ cell3, text=3)
+ self._fsTreeView.insert_column(mountPointColumn, 3)
+ cell0.connect('toggled', self._row_toggled)
+ cell1.connect('toggled', self._rsync_cell_toggled)
+ advancedBox = self._xml.get_widget("advancedbox")
+ advancedBox.connect('unmap', self._advancedbox_unmap)
+
+ self._rsyncSMF = rsyncsmf.RsyncSMF("%s:rsync" \
+ %(plugin.PLUGINBASEFMRI))
+ state = self._rsyncSMF.get_service_state()
+ self._initialRsyncTargetDir = self._rsyncSMF.get_target_dir()
+ # Check for the default, unset value of "" from SMF.
+ if self._initialRsyncTargetDir == '""':
+ self._initialRsyncTargetDir = ''
+ self._newRsyncTargetDir = self._initialRsyncTargetDir
+ self._smfTargetKey = self._rsyncSMF.get_target_key()
+ self._newRsyncTargetSelected = False
+ sys,self._nodeName,rel,ver,arch = os.uname()
+
+ # Model columns:
+ # 0 Themed icon list (python list)
+ # 1 device root
+ # 2 volume name
+ # 3 Is gio.Mount device
+ # 4 Is separator (for comboBox separator rendering)
+ self._rsyncStore = gtk.ListStore(gobject.TYPE_PYOBJECT,
+ gobject.TYPE_STRING,
+ gobject.TYPE_STRING,
+ gobject.TYPE_BOOLEAN,
+ gobject.TYPE_BOOLEAN)
+ self._rsyncCombo = self._xml.get_widget("rsyncdevcombo")
+ mounts = self._vm.get_mounts()
+ for mount in mounts:
+ self._mount_added(self._vm, mount)
+ if len(mounts) > 0:
+ # Add a separator
+ self._rsyncStore.append((None, None, None, None, True))
+ del mounts
+
+ if len(self._newRsyncTargetDir) == 0:
+ self._rsyncStore.append((['folder'],
+ _("(None)"),
+ '',
+ False,
+ False))
+ # Add a separator
+ self._rsyncStore.append((None, None, None, None, True))
+ self._rsyncStore.append((None, _("Other..."), "Other", False, False))
+ self._iconCell = gtk.CellRendererPixbuf()
+ self._nameCell = gtk.CellRendererText()
+ self._rsyncCombo.clear()
+ self._rsyncCombo.pack_start(self._iconCell, False)
+ self._rsyncCombo.set_cell_data_func(self._iconCell,
+ self._icon_cell_render)
+ self._rsyncCombo.pack_end(self._nameCell)
+ self._rsyncCombo.set_attributes(self._nameCell, text=1)
+ self._rsyncCombo.set_row_separator_func(self._row_separator)
+ self._rsyncCombo.set_model(self._rsyncStore)
+ self._rsyncCombo.connect("changed", self._rsync_combo_changed)
+ # Force selection of currently configured device
+ self._rsync_dev_selected(self._newRsyncTargetDir)
+
+ # signal dictionary
+ dic = {"on_ok_clicked" : self._on_ok_clicked,
+ "on_cancel_clicked" : gtk.main_quit,
+ "on_snapshotmanager_delete_event" : gtk.main_quit,
+ "on_enablebutton_toggled" : self._on_enablebutton_toggled,
+ "on_rsyncbutton_toggled" : self._on_rsyncbutton_toggled,
+ "on_defaultfsradio_toggled" : self._on_defaultfsradio_toggled,
+ "on_selectfsradio_toggled" : self._on_selectfsradio_toggled,
+ "on_deletesnapshots_clicked" : self._on_deletesnapshots_clicked}
+ self._xml.signal_autoconnect(dic)
+
+ if state != "disabled":
+ self._rsyncEnabled = True
+ self._xml.get_widget("rsyncbutton").set_active(True)
+ self._initialRsyncState = True
+ else:
+ self._rsyncEnabled = False
+ self._rsyncCombo.set_sensitive(False)
+ self._initialRsyncState = False
+
+ # Initialise SMF service instance state.
+ try:
+ self._sliderSMF = TimeSliderSMF()
+ except RuntimeError,message:
+ self._xml.get_widget("toplevel").set_sensitive(False)
+ dialog = gtk.MessageDialog(self._xml.get_widget("toplevel"),
+ 0,
+ gtk.MESSAGE_ERROR,
+ gtk.BUTTONS_CLOSE,
+ _("Snapshot manager service error"))
+ dialog.format_secondary_text(_("The snapshot manager service does "
+ "not appear to be installed on this "
+ "system."
+ "\n\nSee the svcs(1) man page for more "
+ "information."
+ "\n\nDetails:\n%s")%(message))
+ dialog.set_icon_name("time-slider-setup")
+ dialog.run()
+ sys.exit(1)
+
+ if self._sliderSMF.svcstate == "disabled":
+ self._xml.get_widget("enablebutton").set_active(False)
+ self._initialEnabledState = False
+ elif self._sliderSMF.svcstate == "offline":
+ self._xml.get_widget("toplevel").set_sensitive(False)
+ errors = ''.join("%s\n" % (error) for error in \
+ self._sliderSMF.find_dependency_errors())
+ dialog = gtk.MessageDialog(self._xml.get_widget("toplevel"),
+ 0,
+ gtk.MESSAGE_ERROR,
+ gtk.BUTTONS_CLOSE,
+ _("Snapshot manager service dependency error"))
+ dialog.format_secondary_text(_("The snapshot manager service has "
+ "been placed offline due to a dependency "
+ "problem. The following dependency problems "
+ "were found:\n\n%s\n\nRun \"svcs -xv\" from "
+ "a command prompt for more information about "
+ "these dependency problems.") % errors)
+ dialog.set_icon_name("time-slider-setup")
+ dialog.run()
+ sys.exit(1)
+ elif self._sliderSMF.svcstate == "maintenance":
+ self._xml.get_widget("toplevel").set_sensitive(False)
+ dialog = gtk.MessageDialog(self._xml.get_widget("toplevel"),
+ 0,
+ gtk.MESSAGE_ERROR,
+ gtk.BUTTONS_CLOSE,
+ _("Snapshot manager service error"))
+ dialog.format_secondary_text(_("The snapshot manager service has "
+ "encountered a problem and has been "
+ "disabled until the problem is fixed."
+ "\n\nSee the svcs(1) man page for more "
+ "information."))
+ dialog.set_icon_name("time-slider-setup")
+ dialog.run()
+ sys.exit(1)
+ else:
+ # FIXME: Check transitional states
+ self._xml.get_widget("enablebutton").set_active(True)
+ self._initialEnabledState = True
+
+
+ # Emit a toggled signal so that the initial GUI state is consistent
+ self._xml.get_widget("enablebutton").emit("toggled")
+ # Check the snapshotting policy (UserData (default), or Custom)
+ self._initialCustomSelection = self._sliderSMF.is_custom_selection()
+ if self._initialCustomSelection == True:
+ self._xml.get_widget("selectfsradio").set_active(True)
+ # Show the advanced controls so the user can see the
+ # customised configuration.
+ if self._sliderSMF.svcstate != "disabled":
+ self._xml.get_widget("expander").set_expanded(True)
+ else: # "false" or any other non "true" value
+ self._xml.get_widget("defaultfsradio").set_active(True)
+
+ # Set the cleanup threshhold value
+ spinButton = self._xml.get_widget("capspinbutton")
+ critLevel = self._sliderSMF.get_cleanup_level("critical")
+ warnLevel = self._sliderSMF.get_cleanup_level("warning")
+
+ # Force the warning level to something practical
+ # on the lower end, and make it no greater than the
+ # critical level specified in the SVC instance.
+ spinButton.set_range(70, critLevel)
+ self._initialCleanupLevel = warnLevel
+ if warnLevel > 70:
+ spinButton.set_value(warnLevel)
+ else:
+ spinButton.set_value(70)
+
+ def _icon_cell_render(self, celllayout, cell, model, iter):
+ iconList = self._rsyncStore.get_value(iter, 0)
+ if iconList != None:
+ gicon = gio.ThemedIcon(iconList)
+ cell.set_property("gicon", gicon)
+ else:
+ root = self._rsyncStore.get_value(iter, 2)
+ if root == "Other":
+ cell.set_property("gicon", None)
+
+ def _row_separator(self, model, iter):
+ return model.get_value(iter, 4)
+
+ def _mount_added(self, volume_monitor, mount):
+ icon = mount.get_icon()
+ iconList = icon.get_names()
+ if iconList == None:
+ iconList = ['drive-harddisk', 'drive']
+ root = mount.get_root()
+ path = root.get_path()
+ mountName = mount.get_name()
+ volume = mount.get_volume()
+ if volume == None:
+ volName = mount.get_name()
+ if volName == None:
+ volName = os.path.split(path)[1]
+ else:
+ volName = volume.get_name()
+
+ # Check to see if there is at least one gio.Mount device already
+ # in the ListStore. If not, then we also need to add a separator
+ # row.
+ iter = self._rsyncStore.get_iter_first()
+ if iter and self._rsyncStore.get_value(iter, 3) == False:
+ self._rsyncStore.insert(0, (None, None, None, None, True))
+
+ self._rsyncStore.insert(0, (iconList, volName, path, True, False))
+ # If this happens to be the already configured backup device
+ # and the user hasn't tried to change device yet, auto select
+ # it.
+ if self._initialRsyncTargetDir == self._newRsyncTargetDir:
+ if self._validate_rsync_target(path) == True:
+ self._rsyncCombo.set_active(0)
+
+ def _mount_removed(self, volume_monitor, mount):
+ root = mount.get_root()
+ path = root.get_path()
+ iter = self._rsyncStore.get_iter_first()
+ mountIter = None
+ numMounts = 0
+ # Search gio.Mount devices
+ while iter != None and \
+ self._rsyncStore.get_value(iter, 3) == True:
+ numMounts += 1
+ compPath = self._rsyncStore.get_value(iter, 2)
+ if compPath == path:
+ mountIter = iter
+ break
+ else:
+ iter = self._rsyncStore.iter_next(iter)
+ if mountIter != None:
+ if numMounts == 1:
+ # Need to remove the separator also since
+ # there will be no more gio.Mount devices
+ # shown in the combo box
+ sepIter = self._rsyncStore.iter_next(mountIter)
+ if self._rsyncStore.get_value(sepIter, 4) == True:
+ self._rsyncStore.remove(sepIter)
+ self._rsyncStore.remove(mountIter)
+ iter = self._rsyncStore.get_iter_first()
+ # Insert a custom folder if none exists already
+ if self._rsyncStore.get_value(iter, 2) == "Other":
+ path = self._initialRsyncTargetDir
+ length = len(path)
+ if length > 1:
+ name = os.path.split(path)[1]
+ elif length == 1:
+ name = path
+ else: # Indicates path is unset: ''
+ name = _("(None)")
+ iter = self._rsyncStore.insert_before(iter,
+ (None,
+ None,
+ None,
+ None,
+ True))
+ iter = self._rsyncStore.insert_before(iter,
+ (['folder'],
+ name,
+ path,
+ False,
+ False))
+ self._rsyncCombo.set_active_iter(iter)
+
+ def _monitor_setup(self, pulseBar):
+ if self._enabler.isAlive() == True:
+ pulseBar.pulse()
+ return True
+ else:
+ gtk.main_quit()
+
+ def _row_toggled(self, renderer, path):
+ model = self._fsTreeView.get_model()
+ iter = model.get_iter(path)
+ state = renderer.get_active()
+ if state == False:
+ self._fsListStore.set_value(iter, 0, True)
+ else:
+ self._fsListStore.set_value(iter, 0, False)
+ self._fsListStore.set_value(iter, 1, False)
+
+ def _rsync_cell_toggled(self, renderer, path):
+ model = self._fsTreeView.get_model()
+ iter = model.get_iter(path)
+ state = renderer.get_active()
+ rowstate = self._fsListStore.get_value(iter, 0)
+ if rowstate == True:
+ if state == False:
+ self._fsListStore.set_value(iter, 1, True)
+ else:
+ self._fsListStore.set_value(iter, 1, False)
+
+ def _rsync_config_error(self, msg):
+ topLevel = self._xml.get_widget("toplevel")
+ dialog = gtk.MessageDialog(topLevel,
+ 0,
+ gtk.MESSAGE_ERROR,
+ gtk.BUTTONS_CLOSE,
+ _("Unsuitable Backup Location"))
+ dialog.format_secondary_text(msg)
+ dialog.set_icon_name("time-slider-setup")
+ dialog.run()
+ dialog.hide()
+ return
+
+ def _rsync_dev_selected(self, path):
+ iter = self._rsyncStore.get_iter_first()
+ while iter != None:
+ # Break out when we hit a non gio.Mount device
+ if self._rsyncStore.get_value(iter, 3) == False:
+ break
+ compPath = self._rsyncStore.get_value(iter, 2)
+ if compPath == path:
+ self._rsyncCombo.set_active_iter(iter)
+ self._newRsyncTargetDir = path
+ return
+ else:
+ iter = self._rsyncStore.iter_next(iter)
+
+ # Not one of the shortcut RMM devices, so it's
+ # some other path on the filesystem.
+ # iter may be pointing at a separator. Increment
+ # to next row iter if so.
+ if self._rsyncStore.get_value(iter, 4) == True:
+ iter = self._rsyncStore.iter_next(iter)
+
+ if iter != None:
+ if len(path) > 1:
+ name = os.path.split(path)[1]
+ elif len(path) == 1:
+ name = path
+ else: # Indicates path is unset: ''
+ name = _("(None)")
+ # Could be either the custom folder selection
+ # row or the "Other" row if the custom row
+ # was not created. If "Other" then create the
+ # custom row and separator now at this position
+ if self._rsyncStore.get_value(iter, 2) == "Other":
+ iter = self._rsyncStore.insert_before(iter,
+ (None,
+ None,
+ None,
+ None,
+ True))
+ iter = self._rsyncStore.insert_before(iter,
+ (['folder'],
+ name,
+ path,
+ False,
+ False))
+ else:
+ self._rsyncStore.set(iter,
+ 1, name,
+ 2, path)
+ self._rsyncCombo.set_active_iter(iter)
+ self._newRsyncTargetDir = path
+
+ def _rsync_combo_changed(self, combobox):
+ newIter = combobox.get_active_iter()
+ if newIter != None:
+ root = self._rsyncStore.get_value(newIter, 2)
+ if root != "Other":
+ self._newRsyncTargetDir = root
+ else:
+ msg = _("Select A Back Up Device")
+ fileDialog = \
+ gtk.FileChooserDialog(
+ msg,
+ self._xml.get_widget("toplevel"),
+ gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER,
+ (gtk.STOCK_CANCEL,gtk.RESPONSE_CANCEL,
+ gtk.STOCK_OK,gtk.RESPONSE_OK),
+ None)
+ self._rsyncCombo.set_sensitive(False)
+ response = fileDialog.run()
+ fileDialog.hide()
+ if response == gtk.RESPONSE_OK:
+ gFile = fileDialog.get_file()
+ self._rsync_dev_selected(gFile.get_path())
+ else:
+ self._rsync_dev_selected(self._newRsyncTargetDir)
+ self._rsyncCombo.set_sensitive(True)
+
+ def _rsync_size_warning(self, zpools, zpoolSize,
+ rsyncTarget, targetSize):
+ # Using decimal "GB" instead of binary "GiB"
+ KB = 1000
+ MB = 1000 * KB
+ GB = 1000 * MB
+ TB = 1000 * GB
+
+ suggestedSize = RSYNCTARGETRATIO * zpoolSize
+ if suggestedSize > TB:
+ sizeStr = "%.1f TB" % round(suggestedSize / float(TB), 1)
+ elif suggestedSize > GB:
+ sizeStr = "%.1f GB" % round(suggestedSize / float(GB), 1)
+ else:
+ sizeStr = "%.1f MB" % round(suggestedSize / float(MB), 1)
+
+ if targetSize > TB:
+ targetStr = "%.1f TB" % round(targetSize / float(TB), 1)
+ elif targetSize > GB:
+ targetStr = "%.1f GB" % round(targetSize / float(GB), 1)
+ else:
+ targetStr = "%.1f MB" % round(targetSize / float(MB), 1)
+
+
+ msg = _("Time Slider suggests a device with a capacity of at "
+ "least <b>%s</b>.\n"
+ "The device: \'<b>%s</b>\'\nonly has <b>%s</b>\n"
+ "Do you want to use it anyway?") \
+ % (sizeStr, rsyncTarget, targetStr)
+
+ topLevel = self._xml.get_widget("toplevel")
+ dialog = gtk.MessageDialog(topLevel,
+ 0,
+ gtk.MESSAGE_QUESTION,
+ gtk.BUTTONS_YES_NO,
+ _("Time Slider"))
+ dialog.set_default_response(gtk.RESPONSE_NO)
+ dialog.set_transient_for(topLevel)
+ dialog.set_markup(msg)
+ dialog.set_icon_name("time-slider-setup")
+
+ response = dialog.run()
+ dialog.hide()
+ if response == gtk.RESPONSE_YES:
+ return True
+ else:
+ return False
+
+ def _check_rsync_config(self):
+ """
+ Checks rsync configuration including, filesystem selection,
+ target directory validation and capacity checks.
+ Returns True if everything is OK, otherwise False.
+ Pops up blocking error dialogs to notify users of error
+ conditions before returning.
+ """
+ def _get_mount_point(path):
+ if os.path.ismount(path):
+ return path
+ else:
+ return _get_mount_point(abspath(join(path, pardir)))
+
+ if self._rsyncEnabled != True:
+ return True
+
+ if len(self._newRsyncTargetDir) == 0:
+ msg = _("No backup device was selected.\n"
+ "Please select an empty device.")
+ self._rsync_config_error(msg)
+ return False
+ # There's little that can be done if the device is from a
+ # previous configuration and currently offline. So just
+ # treat it as being OK based on the assumption that it was
+ # previously deemed to be OK.
+ if self._initialRsyncTargetDir == self._newRsyncTargetDir and \
+ not os.path.exists(self._newRsyncTargetDir):
+ return True
+ # Perform the required validation checks on the
+ # target directory.
+ newTargetDir = self._newRsyncTargetDir
+
+ # We require the whole device. So find the enclosing
+ # mount point and inspect from there.
+ targetMountPoint = abspath(_get_mount_point(newTargetDir))
+
+ # Check that it's writable.
+ f = None
+ testFile = os.path.join(targetMountPoint, ".ts-test")
+ try:
+ f = open(testFile, 'w')
+ except (OSError, IOError):
+ msg = _("\'%s\'\n"
+ "is not writable. The backup device must "
+ "be writable by the system administrator."
+ "\n\nPlease use a different device.") \
+ % (targetMountPoint)
+ self._rsync_config_error(msg)
+ return False
+ f.close()
+
+ # Try to create a symlink. Rsync requires this to
+ # do incremental backups and to ensure it's posix like
+ # enough to correctly set file ownerships and perms.
+ os.chdir(targetMountPoint)
+ try:
+ os.link(testFile, ".ts-test-link")
+ except OSError:
+ msg = _("\'%s\'\n"
+ "contains an incompatible file system. "
+ "The selected device must have a Unix "
+ "style file system that supports file "
+ "linking, such as UFS"
+ "\n\nPlease use a different device.") \
+ % (targetMountPoint)
+ self._rsync_config_error(msg)
+ return False
+ finally:
+ os.unlink(testFile)
+ os.unlink(".ts-test-link")
+
+ # Check that selected directory is either empty
+ # or already preconfigured as a backup target
+ sys,nodeName,rel,ver,arch = os.uname()
+ basePath = os.path.join(targetMountPoint,
+ rsyncsmf.RSYNCDIRPREFIX)
+ nodePath = os.path.join(basePath,
+ nodeName)
+ configPath = os.path.join(basePath,
+ rsyncsmf.RSYNCCONFIGFILE)
+ self._newRsyncTargetSelected = True
+ targetDirKey = None
+
+ contents = os.listdir(targetMountPoint)
+ os.chdir(targetMountPoint)
+
+ # The only other exception to an empty directory is
+ # "lost+found".
+ for item in contents:
+ if (item != rsyncsmf.RSYNCDIRPREFIX and \
+ item != "lost+found") or \
+ not os.path.isdir(item) or \
+ os.path.islink(item):
+ msg = _("\'%s\'\n is not an empty device.\n\n"
+ "Please select an empty device.") \
+ % (newTargetDir)
+ self._rsync_config_error(msg)
+ return False
+
+ # Validate existing directory structure
+ if os.path.exists(basePath):
+ # We only accept a pre-existing directory if
+ # 1. It has a config key that matches that stored by
+ # the rsync plugin's SMF configuration
+ # 2. It has a single subfolder that matches the nodename
+ # of this system,
+
+ # Check for previous config key
+ if os.path.exists(configPath):
+ f = open(configPath, 'r')
+ for line in f.readlines():
+ key, val = line.strip().split('=')
+ if key.strip() == "target_key":
+ targetDirKey = val.strip()
+ break
+
+ # Examine anything else in the directory
+ self._targetSelectionError = None
+ dirList = [d for d in os.listdir(basePath) if
+ d != '.rsync-config']
+ os.chdir(basePath)
+ if len(dirList) > 0:
+ msg = _("\'%s\'\n is not an empty device.\n\n"
+ "Please select an empty device.") \
+ % (newTargetDir)
+ # No config key or > 1 directory:
+ # User specified a non empty directory.
+ if targetDirKey == None or len(dirList) > 1:
+ self._rsync_config_error(msg)
+ return False
+ # Make sure the single item is not a file or symlink.
+ elif os.path.islink(dirList[0]) or \
+ os.path.isfile(dirList[0]):
+ self._rsync_config_error(msg)
+ return False
+ else:
+ # Has 1 other item and a config key. Other
+ # item must be a directory and must match the
+ # system nodename and SMF's key value respectively
+ # respectively
+ if dirList[0] != nodeName and \
+ targetDirKey != self._smfTargetKey:
+ msg = _("\'%s\'\n"
+ "is a Time Slider external backup device "
+ "that is already in use by another system. "
+ "Backup devices may not be shared between "
+ "systems."
+ "\n\nPlease use a different device.") \
+ % (newTargetDir)
+ self._rsync_config_error(msg)
+ return False
+ else:
+ if dirList[0] == nodeName and \
+ targetDirKey != self._smfTargetKey:
+ # Looks like a device that we previously used,
+ # but discontinued using in favour of some other
+ # device.
+ msg = _("\'<b>%s</b>\' appears to be a a device "
+ "previously configured for use by this "
+ "system.\n\nDo you want resume use of "
+ "this device for backups?") \
+ % (newTargetDir)
+
+ topLevel = self._xml.get_widget("toplevel")
+ dialog = gtk.MessageDialog(topLevel,
+ 0,
+ gtk.MESSAGE_QUESTION,
+ gtk.BUTTONS_YES_NO,
+ _("Time Slider"))
+ dialog.set_default_response(gtk.RESPONSE_NO)
+ dialog.set_transient_for(topLevel)
+ dialog.set_markup(msg)
+ dialog.set_icon_name("time-slider-setup")
+
+ response = dialog.run()
+ dialog.hide()
+ if response == gtk.RESPONSE_NO:
+ return False
+ else:
+ # Appears to be our own pre-configured directory.
+ self._newRsyncTargetSelected = False
+
+ # Compare device ID against selected ZFS filesystems
+ # and their enclosing Zpools. The aim is to avoid
+ # a vicous circle caused by backing up snapshots onto
+ # the same pool the snapshots originate from
+ targetDev = os.stat(newTargetDir).st_dev
+ try:
+ fs = self._fsDevices[targetDev]
+
+ # See if the filesystem itself is selected
+ # and/or any other fileystem on the pool is
+ # selected.
+ fsEnabled = self._snapStateDic[fs.name]
+ if fsEnabled == True:
+ # Definitely can't use this since it's a
+ # snapshotted filesystem.
+ msg = _("\'%s\'\n"
+ "belongs to the ZFS filesystem \'%s\' "
+ "which is already selected for "
+ "regular ZFS snaphots."
+ "\n\nPlease select a drive "
+ "not already in use by "
+ "Time Slider") \
+ % (newTargetDir, fs.name)
+ self._rsync_config_error(msg)
+ return False
+ else:
+ # See if there is anything else on the pool being
+ # snapshotted
+ poolName = fs.name.split("/", 1)[0]
+ for name,mount in self._datasets.list_filesystems():
+ if name.find(poolName) == 0:
+ try:
+ otherEnabled = self._snapStateDic[name]
+ radioBtn = self._xml.get_widget("defaultfsradio")
+ snapAll = radioBtn.get_active()
+ if snapAll or otherEnabled:
+ msg = _("\'%s\'\n"
+ "belongs to the ZFS pool \'%s\' "
+ "which is already being used "
+ "to store ZFS snaphots."
+ "\n\nPlease select a drive "
+ "not already in use by "
+ "Time Slider") \
+ % (newTargetDir, poolName)
+ self._rsync_config_error(msg)
+ return False
+ except KeyError:
+ pass
+ except KeyError:
+ # No match found - good.
+ pass
+
+
+ # Figure out if there's a reasonable amount of free space to
+ # store backups. This is a vague guess at best.
+ allPools = zfs.list_zpools()
+ snapPools = []
+ # FIXME - this is for custom selection. There is a short
+ # circuit case for default (All) configuration. Don't forget
+ # to implement this short circuit.
+ for poolName in allPools:
+ try:
+ snapPools.index(poolName)
+ except ValueError:
+ pool = zfs.ZPool(poolName)
+ # FIXME - we should include volumes here but they
+ # can only be set from the command line, not via
+ # the GUI, so not crucial.
+ for fsName,mount in pool.list_filesystems():
+ # Don't try to catch exception. The filesystems
+ # are already populated in self._snapStateDic
+ enabled = self._snapStateDic[fsName]
+ if enabled == True:
+ snapPools.append(poolName)
+ break
+
+ sumPoolSize = 0
+ for poolName in snapPools:
+ pool = zfs.ZPool(poolName)
+ # Rough calcualation, but precise enough for
+ # estimation purposes
+ sumPoolSize += pool.get_used_size()
+ sumPoolSize += pool.get_available_size()
+
+
+ # Compare with available space on rsync target dir
+ targetAvail = util.get_available_size(targetMountPoint)
+ targetUsed = util.get_used_size(targetMountPoint)
+ targetSum = targetAvail + targetUsed
+
+ # Recommended Minimum:
+ # At least double the combined size of all pools with
+ # fileystems selected for backup. Variables include,
+ # frequency of data changes, how much efficiency rsync
+ # sacrifices compared to ZFS' block level diff tracking,
+ # whether compression and/or deduplication are enabled
+ # on the source pool/fileystem.
+ # We don't try to make calculations based on individual
+ # filesystem selection as there are too many unpredictable
+ # variables to make an estimation of any practical use.
+ # Let the user figure that out for themselves.
+
+ # The most consistent measurement is to use the sum of
+ # available and used size on the target fileystem. We
+ # assume based on previous checks that the target device
+ # is only being used for rsync backups and therefore the
+ # used value consists of existing backups and is. Available
+ # space can be reduced for various reasons including the used
+ # value increasing or for nfs mounted zfs fileystems, other
+ # zfs filesystems on the containing pool using up more space.
+
+
+ targetPoolRatio = targetSum/float(sumPoolSize)
+ if (targetPoolRatio < RSYNCTARGETRATIO):
+ response = self._rsync_size_warning(snapPools,
+ sumPoolSize,
+ targetMountPoint,
+ targetSum)
+ if response == False:
+ return False
+
+ self._newRsyncTargetDir = targetMountPoint
+ return True
+
+ def _on_ok_clicked(self, widget):
+ # Make sure the dictionaries are empty.
+ self._fsIntentDic = {}
+ self._snapStateDic = {}
+ self._rsyncStateDic = {}
+ enabled = self._xml.get_widget("enablebutton").get_active()
+ self._rsyncEnabled = self._xml.get_widget("rsyncbutton").get_active()
+ if enabled == False:
+ if self._rsyncEnabled == False and \
+ self._initialRsyncState == True:
+ self._rsyncSMF.disable_service()
+ if self._initialEnabledState == True:
+ self._sliderSMF.disable_service()
+ # Ignore other changes to the snapshot/rsync configuration
+ # of filesystems. Just broadcast the change and exit.
+ self._configNotify = True
+ self.broadcast_changes()
+ gtk.main_quit()
+ else:
+ model = self._fsTreeView.get_model()
+ snapalldata = self._xml.get_widget("defaultfsradio").get_active()
+
+ if snapalldata == True:
+ model.foreach(self._set_fs_selection_state, True)
+ if self._rsyncEnabled == True:
+ model.foreach(self._set_rsync_selection_state, True)
+ else:
+ model.foreach(self._get_fs_selection_state)
+ model.foreach(self._get_rsync_selection_state)
+ for fsname in self._snapStateDic:
+ self._refine_filesys_actions(fsname,
+ self._snapStateDic,
+ self._fsIntentDic)
+ if self._rsyncEnabled == True:
+ self._refine_filesys_actions(fsname,
+ self._rsyncStateDic,
+ self._rsyncIntentDic)
+ if self._rsyncEnabled and \
+ not self._check_rsync_config():
+ return
+
+ self._pulseDialog.show()
+ self._enabler = EnableService(self)
+ self._enabler.start()
+ glib.timeout_add(100,
+ self._monitor_setup,
+ self._xml.get_widget("pulsebar"))
+
+ def _on_enablebutton_toggled(self, widget):
+ expander = self._xml.get_widget("expander")
+ enabled = widget.get_active()
+ self._xml.get_widget("filesysframe").set_sensitive(enabled)
+ expander.set_sensitive(enabled)
+ if (enabled == False):
+ expander.set_expanded(False)
+
+ def _on_rsyncbutton_toggled(self, widget):
+ self._rsyncEnabled = widget.get_active()
+ if self._rsyncEnabled == True:
+ self._fsTreeView.insert_column(self._rsyncRadioColumn, 1)
+ self._rsyncCombo.set_sensitive(True)
+ else:
+ self._fsTreeView.remove_column(self._rsyncRadioColumn)
+ self._rsyncCombo.set_sensitive(False)
+
+ def _on_defaultfsradio_toggled(self, widget):
+ if widget.get_active() == True:
+ self._xml.get_widget("fstreeview").set_sensitive(False)
+
+ def _on_selectfsradio_toggled(self, widget):
+ if widget.get_active() == True:
+ self._xml.get_widget("fstreeview").set_sensitive(True)
+
+ def _advancedbox_unmap(self, widget):
+ # Auto shrink the window by subtracting the frame's height
+ # requistion from the window's height requisition
+ myrequest = widget.size_request()
+ toplevel = self._xml.get_widget("toplevel")
+ toprequest = toplevel.size_request()
+ toplevel.resize(toprequest[0], toprequest[1] - myrequest[1])
+
+ def _get_fs_selection_state(self, model, path, iter):
+ fsname = self._fsListStore.get_value(iter, 3)
+ enabled = self._fsListStore.get_value(iter, 0)
+ self._snapStateDic[fsname] = enabled
+
+ def _get_rsync_selection_state(self, model, path, iter):
+ fsname = self._fsListStore.get_value(iter, 3)
+ enabled = self._fsListStore.get_value(iter, 1)
+ self._rsyncStateDic[fsname] = enabled
+
+ def _set_fs_selection_state(self, model, path, iter, selected):
+ fsname = self._fsListStore.get_value(iter, 3)
+ self._snapStateDic[fsname] = selected
+
+ def _set_rsync_selection_state(self, model, path, iter, selected):
+ fsname = self._fsListStore.get_value(iter, 3)
+ self._rsyncStateDic[fsname] = selected
+
+ def _refine_filesys_actions(self, fsname, inputdic, actions):
+ selected = inputdic[fsname]
+ try:
+ fstag = actions[fsname]
+ # Found so we can skip over.
+ except KeyError:
+ # Need to check parent value to see if
+ # we should set explicitly or just inherit.
+ path = fsname.rsplit("/", 1)
+ parentName = path[0]
+ if parentName == fsname:
+ # Means this filesystem is the root of the pool
+ # so we need to set it explicitly.
+ actions[fsname] = \
+ FilesystemIntention(fsname, selected, False)
+ else:
+ parentIntent = None
+ inherit = False
+ # Check if parent is already set and if so whether to
+ # inherit or override with a locally set property value.
+ try:
+ # Parent has already been registered
+ parentIntent = actions[parentName]
+ except:
+ # Parent not yet set, so do that recursively to figure
+ # out if we need to inherit or set a local property on
+ # this child filesystem.
+ self._refine_filesys_actions(parentName,
+ inputdic,
+ actions)
+ parentIntent = actions[parentName]
+ if parentIntent.selected == selected:
+ inherit = True
+ actions[fsname] = \
+ FilesystemIntention(fsname, selected, inherit)
+
+ 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
+ """
+ # FIXME - this is duplicate in applet.py and rsync-backup.py
+ # It should be moved into a shared module
+ if not os.path.exists(path):
+ return False
+ testDir = os.path.join(path,
+ rsyncsmf.RSYNCDIRPREFIX,
+ self._nodeName)
+ testKeyFile = os.path.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._smfTargetKey:
+ return True
+ return False
+
+
+ def commit_filesystem_selection(self):
+ """
+ Commits the intended filesystem selection actions based on the
+ user's UI configuration to disk. Compares with initial startup
+ configuration and applies the minimum set of necessary changes.
+ """
+ for fsname,fsmountpoint in self._datasets.list_filesystems():
+ fs = zfs.Filesystem(fsname, fsmountpoint)
+ try:
+ initialIntent = self._initialFsIntentDic[fsname]
+ intent = self._fsIntentDic[fsname]
+ if intent == initialIntent:
+ continue
+ fs.set_auto_snap(intent.selected, intent.inherited)
+
+ except KeyError:
+ pass
+
+ def commit_rsync_selection(self):
+ """
+ Commits the intended filesystem selection actions based on the
+ user's UI configuration to disk. Compares with initial startup
+ configuration and applies the minimum set of necessary changes.
+ """
+ for fsname,fsmountpoint in self._datasets.list_filesystems():
+ fs = zfs.Filesystem(fsname, fsmountpoint)
+ try:
+ initialIntent = self._initialRsyncIntentDic[fsname]
+ intent = self._rsyncIntentDic[fsname]
+ if intent == initialIntent:
+ continue
+ if intent.inherited == True and \
+ initialIntent.inherited == False:
+ fs.unset_user_property(rsyncsmf.RSYNCFSTAG)
+ else:
+ if intent.selected == True:
+ value = "true"
+ else:
+ value = "false"
+ fs.set_user_property(rsyncsmf.RSYNCFSTAG,
+ value)
+ except KeyError:
+ pass
+
+ def setup_rsync_config(self):
+ if self._rsyncEnabled == True:
+ if self._newRsyncTargetSelected == True:
+ sys,nodeName,rel,ver,arch = os.uname()
+ basePath = os.path.join(self._newRsyncTargetDir,
+ rsyncsmf.RSYNCDIRPREFIX,)
+ nodePath = os.path.join(basePath,
+ nodeName)
+ configPath = os.path.join(basePath,
+ rsyncsmf.RSYNCCONFIGFILE)
+ newKey = generate_random_key()
+ try:
+ origmask = os.umask(0222)
+ if not os.path.exists(nodePath):
+ os.makedirs(nodePath, 0755)
+ f = open(configPath, 'w')
+ f.write("target_key=%s\n" % (newKey))
+ f.close()
+ os.umask(origmask)
+ except OSError as e:
+ self._pulseDialog.hide()
+ sys.stderr.write("Error configuring external " \
+ "backup device:\n" \
+ "%s\n\nReason:\n %s") \
+ % (self._newRsyncTargetDir, str(e))
+ sys.exit(-1)
+ self._rsyncSMF.set_target_dir(self._newRsyncTargetDir)
+ self._rsyncSMF.set_target_key(newKey)
+ # Applet monitors rsyncTargetDir so make sure to notify it.
+ self._configNotify = True
+ return
+
+ def setup_services(self):
+ # Take care of the rsync plugin service first since time-slider
+ # will query it.
+ # Changes to rsync or time-slider SMF service State should be
+ # broadcast to let notification applet refresh.
+ if self._rsyncEnabled == True and \
+ self._initialRsyncState == False:
+ self._rsyncSMF.enable_service()
+ self._configNotify = True
+ elif self._rsyncEnabled == False and \
+ self._initialRsyncState == True:
+ self._rsyncSMF.disable_service()
+ self._configNotify = True
+ customSelection = self._xml.get_widget("selectfsradio").get_active()
+ if customSelection != self._initialCustomSelection:
+ self._sliderSMF.set_custom_selection(customSelection)
+ if self._initialEnabledState == False:
+ enable_default_schedules()
+ self._sliderSMF.enable_service()
+ self._configNotify = True
+
+ def set_cleanup_level(self):
+ """
+ Wrapper function to set the warning level cleanup threshold
+ value as a percentage of pool capacity.
+ """
+ level = self._xml.get_widget("capspinbutton").get_value_as_int()
+ if level != self._initialCleanupLevel:
+ self._sliderSMF.set_cleanup_level("warning", level)
+
+ def broadcast_changes(self):
+ """
+ Blunt instrument to notify D-Bus listeners such as notification
+ applet to rescan service configuration
+ """
+ if self._configNotify == False:
+ return
+ self._dbus.config_changed()
+
+ def _on_deletesnapshots_clicked(self, widget):
+ cmdpath = os.path.join(os.path.dirname(self._execPath), \
+ "../lib/time-slider-delete")
+ p = subprocess.Popen(cmdpath, close_fds=True)
+
+
+class EnableService(threading.Thread):
+
+ def __init__(self, setupManager):
+ threading.Thread.__init__(self)
+ self._setupManager = setupManager
+
+ def run(self):
+ try:
+ # Set the service state last so that the ZFS filesystems
+ # are correctly tagged before the snapshot scripts check them
+ self._setupManager.commit_filesystem_selection()
+ self._setupManager.commit_rsync_selection()
+ self._setupManager.set_cleanup_level()
+ self._setupManager.setup_rsync_config()
+ self._setupManager.setup_services()
+ self._setupManager.broadcast_changes()
+ except RuntimeError, message:
+ sys.stderr.write(str(message))
+
+def generate_random_key(length=32):
+ """
+ Returns a 'length' byte character composed of random letters and
+ unsigned single digit integers. Used to create a random
+ signature key to identify pre-configured backup directories
+ for the rsync plugin
+ """
+ from string import letters, digits
+ from random import choice
+ return ''.join([choice(letters + digits) \
+ for i in range(length)])
+
+def main(argv):
+ rbacp = RBACprofile()
+ # The setup GUI needs to be run as root in order to ensure
+ # that the rsync backup target directory is accessible by
+ # root and to perform validation checks on it.
+ # This GUI can be launched with an euid of root in one of
+ # the following 3 ways;
+ # 0. Run by the superuser (root)
+ # 1. Run via gksu to allow a non priviliged user to authenticate
+ # as the superuser (root)
+
+ if os.geteuid() == 0:
+ manager = SetupManager(argv)
+ gtk.gdk.threads_enter()
+ gtk.main()
+ gtk.gdk.threads_leave()
+ elif os.path.exists(argv) and os.path.exists("/usr/bin/gksu"):
+ # Run via gksu, which will prompt for the root password
+ os.unsetenv("DBUS_SESSION_BUS_ADDRESS")
+ os.execl("/usr/bin/gksu", "gksu", argv)
+ # Shouldn't reach this point
+ sys.exit(1)
+ else:
+ dialog = gtk.MessageDialog(None,
+ 0,
+ gtk.MESSAGE_ERROR,
+ gtk.BUTTONS_CLOSE,
+ _("Insufficient Priviliges"))
+ dialog.format_secondary_text(_("The snapshot manager service requires "
+ "administrative privileges to run. "
+ "You have not been assigned the necessary"
+ "administrative priviliges."
+ "\n\nConsult your system administrator "))
+ dialog.set_icon_name("time-slider-setup")
+ dialog.run()
+ sys.exit(1)
+