From df4e02ebf6a4e28a58abcb298a4442a245ad0b15 Mon Sep 17 00:00:00 2001 From: Michael Orlitzky Date: Sat, 7 Nov 2015 22:34:38 -0500 Subject: [PATCH] Document everything with YARD and fix some bugs along the way. --- bin/mailshears | 7 +- lib/common/agendav_plugin.rb | 19 ++-- lib/common/configuration.rb | 36 ++++++- lib/common/davical_plugin.rb | 38 +++++-- lib/common/domain.rb | 37 +++++++ lib/common/dovecot_plugin.rb | 69 ++++++++++-- lib/common/exit_codes.rb | 2 + lib/common/filesystem.rb | 9 +- lib/common/plugin.rb | 172 +++++++++++++++++++++++------- lib/common/postfixadmin_plugin.rb | 87 +++++++++++++-- lib/common/roundcube_plugin.rb | 33 +++++- lib/common/runner.rb | 23 +++- lib/common/user.rb | 63 ++++++++++- lib/common/user_interface.rb | 62 ++++++++--- lib/mv/mv_dummy_runner.rb | 18 ++++ lib/mv/mv_plugin.rb | 27 ++++- lib/mv/mv_runner.rb | 14 +++ lib/mv/plugins/agendav.rb | 21 +++- lib/mv/plugins/davical.rb | 30 +++--- lib/mv/plugins/dovecot.rb | 51 +++++---- lib/mv/plugins/postfixadmin.rb | 20 +++- lib/mv/plugins/roundcube.rb | 22 +++- lib/prune/plugins/agendav.rb | 5 + lib/prune/plugins/davical.rb | 5 + lib/prune/plugins/dovecot.rb | 5 + lib/prune/plugins/postfixadmin.rb | 17 +-- lib/prune/plugins/roundcube.rb | 6 ++ lib/prune/prune_dummy_runner.rb | 20 ++++ lib/prune/prune_plugin.rb | 56 +++++++--- lib/prune/prune_runner.rb | 10 ++ lib/rm/plugins/agendav.rb | 13 ++- lib/rm/plugins/davical.rb | 21 ++-- lib/rm/plugins/dovecot.rb | 21 +++- lib/rm/plugins/postfixadmin.rb | 77 ++++++++----- lib/rm/plugins/roundcube.rb | 27 ++--- lib/rm/rm_dummy_runner.rb | 21 ++++ lib/rm/rm_plugin.rb | 57 +++++++--- lib/rm/rm_runner.rb | 28 ++++- 38 files changed, 1008 insertions(+), 241 deletions(-) diff --git a/bin/mailshears b/bin/mailshears index c5efabb..d285171 100755 --- a/bin/mailshears +++ b/bin/mailshears @@ -7,8 +7,7 @@ require 'mailshears' # Define a usage string using the program name. -exe = File.basename($PROGRAM_NAME) -usage = "#{exe} [prune | rm | mv ]" +program_name = File.basename($PROGRAM_NAME) # Defaults mode_name = 'prune' @@ -48,7 +47,7 @@ end # here. Report it and exit with a failure code. if not args_error_message.nil? then STDERR.puts args_error_message - puts "Usage: #{usage}" + puts "Usage: #{UserInterface.usage(program_name)}" Kernel.exit(ExitCodes::BAD_COMMAND_LINE) end @@ -115,7 +114,7 @@ ensure $stdout = STDOUT if output_buffer.size > 0 then - puts make_header(exe, plugin_module.to_s()) + puts UserInterface.make_header(program_name, plugin_module.to_s()) puts output_buffer.string() end end diff --git a/lib/common/agendav_plugin.rb b/lib/common/agendav_plugin.rb index 9af7b01..8eb73c1 100644 --- a/lib/common/agendav_plugin.rb +++ b/lib/common/agendav_plugin.rb @@ -1,12 +1,18 @@ require 'common/plugin' require 'common/user' +# Code that all Agendav plugins ({AgendavPrune}, {AgendavRm}, +# {AgendavMv}) share. module AgendavPlugin - # Code that all Agendav plugins (Prune, Rm, Mv...) will - # share. That is, we implement the Plugin interface. + + # We implement the Plugin "interface." include Plugin + # Initialize this Agendav {Plugin} with values in *cfg*. + # + # @param cfg [Configuration] the configuration for this plugin. + # def initialize(cfg) @db_host = cfg.agendav_dbhost @db_port = cfg.agendav_dbport @@ -18,11 +24,12 @@ module AgendavPlugin end + # Return a list of Agendav users. + # + # @return [Array] a list of users contained in the + # Agendav database. + # def list_users() - # - # Produce a list of AgenDAV users. This is public because it's - # useful in testing. - # users = [] connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, diff --git a/lib/common/configuration.rb b/lib/common/configuration.rb index caeab92..b810b02 100644 --- a/lib/common/configuration.rb +++ b/lib/common/configuration.rb @@ -1,10 +1,32 @@ require 'yaml' +# A configuration object that knows how to read options out of a file +# in ~/.mailshears.conf.yml. The configuration options can be +# accessed via methods even though the internal representation is a +# hash. +# +# === Examples +# +# >> cfg = Configuration.new() +# >> cfg.i_mean_business() +# => true +# class Configuration + # The default path to the user's configuration file. USERCONF_PATH = ENV['HOME'] + '/.mailshears.conf.yml' + + # The hash structure in which we store our configuration options + # internally. @dict = {} + + # Initialize a {Configuration} object with the config file at *path*. + # + # @param path [String] the path to the configuration file to + # load. We check for a file named ".mailshears.conf.yml" in the + # user's home directory by default. + # def initialize(path = USERCONF_PATH) cfg = default_configuration() @@ -28,14 +50,26 @@ class Configuration end + # Replace all missing method calls with hash lookups. This lets us + # retrieve the values in our option hash by using methods named + # after the associated keys. + # + # @param sym [Symbol] the method that was called. + # + # @return [Object] the config file value associated with *sym*. + # def method_missing(sym, *args) - # Replace all missing method calls with dictionary lookups. return @dict[sym] end private; + + # A default config hash. + # + # @return [Hash] sensible default configuration values. + # def default_configuration() d = {} diff --git a/lib/common/davical_plugin.rb b/lib/common/davical_plugin.rb index 363e08e..8684cca 100644 --- a/lib/common/davical_plugin.rb +++ b/lib/common/davical_plugin.rb @@ -1,11 +1,18 @@ require 'common/plugin' require 'common/user' +# Code that all DAViCal plugins ({DavicalPrune}, {DavicalRm}, and +# {DavicalMv}) will share. +# module DavicalPlugin - # Code that all Davical plugins (Prune, Rm, Mv...) will share. That - # is, we implement the Plugin interface. + + # We implement the Plugin "interface." include Plugin + # Initialize this DAViCal {Plugin} with values in *cfg*. + # + # @param cfg [Configuration] the configuration for this plugin. + # def initialize(cfg) @db_host = cfg.davical_dbhost @db_port = cfg.davical_dbport @@ -17,17 +24,28 @@ module DavicalPlugin end + # Describe the given DAViCal user who is assumed to exist. + # + # @param user [User] the {User} object whose description we want. + # + # @return [String] a String describing the given *user* in terms + # of his DAViCal "Principal ID". + # def describe_user(user) principal_id = self.get_principal_id(user) return "Principal ID: #{principal_id}" end + # + # Produce a list of DAViCal users. + # + # This method remains public for use in testing. + # + # @return [Array] an array of {User} objects, one for each + # user found in the DAViCal database. + # def list_users() - # - # Produce a list of DAViCal users. This is public because it's - # useful for testing. - # usernames = [] connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, @@ -48,6 +66,14 @@ module DavicalPlugin protected; + + # Find the "Principal ID" of the given user. + # + # @param user [User] the user whose Principal ID we want. + # + # @return [Fixnum] an integer representing the user's Principal ID + # that we obtained from the DAViCal database. + # def get_principal_id(user) principal_id = nil diff --git a/lib/common/domain.rb b/lib/common/domain.rb index ec4fecf..a9e5440 100644 --- a/lib/common/domain.rb +++ b/lib/common/domain.rb @@ -1,9 +1,21 @@ require 'common/errors' +# A class representing a syntactically valid domain. Essentially, the +# part after the "@" in an email address. Once constructed, you can be +# sure that it's valid. +# class Domain + # @domain contains the String representation of this domain. @domain = nil + + # Initialize this Domain object. If the domain is invalid, then an + # {InvalidDomainError} will be raised containing the reason + # why the domain is invalid. + # + # @param domain [String] the string representation of this domain. + # def initialize(domain) if domain.empty? then msg = "domain cannot be empty" @@ -13,14 +25,39 @@ class Domain @domain = domain end + + # Convert this domain to a String. + # + # @return [String] the string representation of this Domain. + # def to_s() return @domain end + + # Check if this Domain is equal to some other Domain. The comparison + # is based on their String representations. + # + # @param other [Domain] the Domain object to compare me to. + # + # @return [Boolean] If *self* and *other* have equal String + # representations, then true is returned. Otherwise, false is + # returned. + # def ==(other) return self.to_s() == other.to_s() end + + # Compare two Domain objects for sorting purposes. The comparison + # is based on their String representations. + # + # @param other [Domain] the Domain object to compare me to. + # + # @return [0,1,-1] a trinary indicator of how *self* relates to *other*, + # obtained by performing the same comparison on the String + # representations of *self* and *other*. + # def <=>(other) return self.to_s() <=> other.to_s() end diff --git a/lib/common/dovecot_plugin.rb b/lib/common/dovecot_plugin.rb index fa61d53..213e99f 100644 --- a/lib/common/dovecot_plugin.rb +++ b/lib/common/dovecot_plugin.rb @@ -3,20 +3,44 @@ require 'common/filesystem' require 'common/plugin' require 'common/user' - +# Code that all Dovecot plugins ({DovecotPrune}, {DovecotRm}, and +# {DovecotMv}) will share. +# module DovecotPlugin - # Code that all Dovecot plugins (Prune, Rm, Mv...) will - # share. That is, we implement the Plugin interface. + + # We implement the Plugin "interface." include Plugin + + # Initialize this Dovecot {Plugin} with values in *cfg*. + # + # @param cfg [Configuration] the configuration for this plugin. + # def initialize(cfg) @domain_root = cfg.dovecot_mail_root end + # Describe the given Dovecot domain by its filesystem path. The + # domain need not exist to obtain its path. + # + # @param domain [Domain] the {Domain} object whose description we want. + # + # @return [String] a String giving the path under which this domain's + # mailboxes would reside on the filesystem. + # def describe_domain(domain) return get_domain_path(domain) end + + # Describe the given Dovecot user by its filesystem mailbox + # path. The user need not exist to obtain its mailbox path. + # + # @param user [User] the {User} object whose description we want. + # + # @return [String] a String giving the path where this user's + # mailbox would reside on the filesystem. + # def describe_user(user) return get_user_path(user) end @@ -24,25 +48,51 @@ module DovecotPlugin protected; + # Return the filesystem path for the given {Domain} object. + # + # @param domain [Domain] the {Domain} whose path we want. + # + # @return [String] the filesystem path where this domain's mail + # would be located. + # def get_domain_path(domain) - # Return the filesystem path for the given domain. - # That is, the directory where its mail is stored. - # Only works if the domain directory exists! return File.join(@domain_root, domain.to_s()) end + # Return the filesystem path of this {User}'s mailbox. + # + # @param user [User] the {User} whose mailbox path we want. + # + # @return [String] the filesystem path where this user's mail + # would be located. + # def get_user_path(user) - # Return the filesystem path of this user's mailbox. domain_path = get_domain_path(user.domain()) return File.join(domain_path, user.localpart()) end + # Produce a list of domains that exist in the Dovecot mailstore. + # + # @return [Array] an array of {Domain} objects that have + # corresponding directories within the Dovecot mailstore. + # def list_domains() return Filesystem.get_subdirs(@domain_root).map{ |d| Domain.new(d) } end + + # Produce a list of users belonging to the given *domains* in the + # Dovecot mailstore. + # + # @param domains [Array] an array of {Domain} objects whose + # users we'd like to find. + # + # @return [Array] an array of {User} objects that have + # corresponding directories within the Dovecot mailstore belonging + # to the specified *domains*. + # def list_domains_users(domains) users = [] @@ -66,6 +116,11 @@ module DovecotPlugin end + # Produce a list of all users in the Dovecot mailstore. + # + # @return [Array] a list of users who have mailbox directories + # within the Dovecot mailstore. + # def list_users() domains = list_domains() users = list_domains_users(domains) diff --git a/lib/common/exit_codes.rb b/lib/common/exit_codes.rb index b7928a7..a0d030b 100644 --- a/lib/common/exit_codes.rb +++ b/lib/common/exit_codes.rb @@ -1,3 +1,5 @@ +# Command-line exit codes. In other words, what you'll see if you +# echo $? after running the executable on the command-line. module ExitCodes # Everything went better than expected. SUCCESS = 0 diff --git a/lib/common/filesystem.rb b/lib/common/filesystem.rb index b44e52c..0c80b59 100644 --- a/lib/common/filesystem.rb +++ b/lib/common/filesystem.rb @@ -1,8 +1,8 @@ +# Convenience methods for working with the filesystem. This class +# only provides static methods, to be used analogously to the File +# class (for example, File.directory?). +# class Filesystem - # Convenience methods for working with the filesystem. This class - # only provides static methods, to be used analogously to the File - # class (for example, File.directory?). - # Return whether or not the given path begins with a dot (ASCII # period). @@ -26,6 +26,7 @@ class Filesystem # def self.get_subdirs(dir) subdirs = [] + return subdirs if not File.directory?(dir) Dir.open(dir) do |d| d.each do |entry| diff --git a/lib/common/plugin.rb b/lib/common/plugin.rb index dba8bec..02fc75a 100644 --- a/lib/common/plugin.rb +++ b/lib/common/plugin.rb @@ -1,38 +1,78 @@ require 'common/domain' require 'common/user' -# All plugins should include this module. It defines the basic -# operations that all plugins are supposed to support. +# Methods that all plugins must provide. Certain operations -- for +# example, user listing -- must be supported by all plugins. These +# operations are defined here, often with naive default +# implementations, but it is up to each individual plugin to ensure +# that they are in fact implemented (well). +# module Plugin + # These are class methods for runnable plugins, meant to be + # _extended_. Those runnable plugins get a magic *run* method but + # need to define their own *runner* and *dummy_runner* to make it + # work. + # module Run - # Module methods, meant to be extended. Includers can explicitly - # extend this to get a run() method defined in terms of their own - # runner() and dummy_runner() methods. + # A callback function, called whenever another class or module + # includes this one. This is used to build a list of all things + # that inherited this class. Having such a list lets us run a + # collection of plugins without knowing in advance what they are. + # + # @param c [Class,Module] the name of the class or module that + # included us. + # def included(c) - # Callback, called whenever another class or module includes this - # one. The parameter given is the name of the class or module - # that included us. + puts c.class().to_s() @includers ||= [] @includers << c end - def includers + + # Obtain the list of classes and modules that have included this one. + # + # @return [Array] the list of classes and modules + # that have included this one. + # + def includers() @includers ||= [] return @includers end + + # The runner class associated with this plugin. This method must + # be supplied by the child class, since they will all have + # different runners. + # + # @return [Class] the runner class associated with this plugin. + # def runner() - # The Runner associated with this plugin. raise NotImplementedError end + + # The "dummy" runner class associated with this plugin. This method + # must be supplied by the child class, since they will all have + # different dummy runners. + # + # @return [Class] the dummy runner class associated with this + # plugin. + # def dummy_runner() - # The DummyRunner associated with this plugin. raise NotImplementedError end + + # Run all of the plugins that have included this module. + # + # @param cfg [Configuration] the configuration options to pass to + # each of the plugins. + # + # @param args [Array] a variable number of additional + # arguments to be passed to the plugins we're running. + # def run(cfg, *args) includers().each do |includer| plugin = includer.new(cfg) @@ -51,9 +91,16 @@ module Plugin end end + + # A generic version of {#describe_user}/{#describe_domain} that + # dispatches base on the class of the target. + # + # @param target [User,Domain] either a user or a domain to describe. + # + # @return [String] a string describing the *target*. The contents of + # the string depend on the plugin. + # def describe(target) - # A generic version of describe_user/describe_domain that - # dispatches base on the class of the target. if target.is_a?(User) if user_exists(target) then return describe_user(target) @@ -71,59 +118,110 @@ module Plugin end end + + # Provide a description of the given *domain*. This is output along + # with the domain name and can be anything of use to the system + # administrator. The default doesn't do anything useful and should + # be overridden if possible. + # + # @param domain [Domain] the domain to describe. + # + # @return [String] a string description of *domain*. + # def describe_domain(domain) - # Provide a "description" of the domain. This is output along with - # the domain name and can be anything of use to the system - # administrator. The default doesn't do anything useful and should - # be overridden. return domain.to_s() end + + # Provide a description of the given *user*. This is output along + # with the username and can be anything of use to the system + # administrator. The default doesn't do anything useful and should + # be overridden if possible. + # + # @param user [User] the domain to describe. + # + # @return [String] a string description of *user*. + # def describe_user(user) - # Provide a "description" of the user. This is output along - # with the domain name and can be anything of use to the system - # administrator. The default doesn't do anything useful and should - # be overridden. return user.to_s() end + + # Return a list of all users managed by this plugin. This must be + # supplied by the individual plugins (who know how to find their + # users). + # + # @return [Array] a list of the users that this plugin knows + # about. + # def list_users() - # Return a list of all users managed by this plugin. raise NotImplementedError end + + # Return a list of all domains managed by this plugin. This must be + # supplied by the individual plugins (who know how to find their + # domains). Many plugins will not have a separate concept of + # "domain", so the default implementation constructs a list of + # domains resulting from {#list_users}. + # + # For plugins that do know about domains, smarter implementations + # are surely possible. + # + # @return [Array] a list of the domains that this plugin knows + # about. + # def list_domains() - # Compute the domains from a list of users. Obviously much worse - # than getting the domains the "smart" way, if such a way exists. users = list_users() domains = users.map{ |u| u.domain() } return domains.uniq() end + + # Does the given *user* exist for this plugin? We use a naive + # implementation here based on {#list_users}. Plugins should override + # this with something faster. + # + # @param user [User] the user whose existence is in question. + # + # @return [Boolean] true if *user* exists for this plugin, and + # false otherwise. + # def user_exists(user) - # Does the given username exist for this plugin? We use a naive - # implementation here based on list_users() which is required to - # exist above. Plugins can override this with something fast. users = list_users() return users.include?(user) end + + # Does the given *domain* exist for this plugin? We use a naive + # implementation here based on {#list_domains}. Plugins that know + # about domains should override this with something fast. + # + # @param domain [Domain] the domain whose existence is in question. + # + # @return [Boolean] true if *domain* exists for this plugin, and + # false otherwise. + # def domain_exists(domain) - # Does the given domain exist for this plugin? We use a naive - # implementation here based on list_domains() which is required to - # exist above. Plugins can override this with something fast. domains = list_domains() return domains.include?(domain) end + + # List all users belonging to the given domains. We say that a user + # belongs to a domain "example.com" if the domain part of the user's + # email address is "example.com". + # + # This uses a naive loop, but relies only on the existence of + # {#list_users}. Plugins that know about domains should provide a + # more efficient implementation. + # + # @param domains [Array] the domains whose users we want. + # + # @return [Array] a list of {User} objects belonging to + # *domains* for this plugin. + # def list_domains_users(domains) - # Get all users belonging to the given domains. If a user has - # domainpart "example.com" then it belongs to the domain - # "example.com". - # - # This uses a naive loop, but relies only on the existence of a - # list_users() method which is guaranteed above. More efficient - # implementations can usually be made within the plugin. domains_users = [] users = list_users(); diff --git a/lib/common/postfixadmin_plugin.rb b/lib/common/postfixadmin_plugin.rb index 3a5494b..8bf03f9 100644 --- a/lib/common/postfixadmin_plugin.rb +++ b/lib/common/postfixadmin_plugin.rb @@ -3,11 +3,18 @@ require 'common/plugin' require 'common/user' require 'pg' +# Code that all Postfixadmin plugins ({PostfixadminPrune}, +# {PostfixadminRm}, {PostfixadminMv}) share. +# module PostfixadminPlugin - # Code that all Postfixadmin plugins (Prune, Rm, Mv...) will - # share. That is, we implement the Plugin interface. + + # We implement the Plugin "interface." include Plugin + # Initialize this Postfixadmin {Plugin} with values in *cfg*. + # + # @param cfg [Configuration] the configuration for this plugin. + # def initialize(cfg) @db_host = cfg.postfixadmin_dbhost @db_port = cfg.postfixadmin_dbport @@ -19,6 +26,13 @@ module PostfixadminPlugin end + # Obtain a list of domains from Postfixadmin. This is more efficient + # than the {Plugin} default implementation because domains have + # their own table in the database and we can easily select them + # rather than filtering the list of users. + # + # @return [Array] a list of the domains in Postfixadmin. + # def list_domains() domains = [] @@ -37,7 +51,11 @@ module PostfixadminPlugin end - + # Return a list of Postfixadmin users. + # + # @return [Array] a list of users contained in the + # Postfixadmin database. + # def list_users() users = [] @@ -55,15 +73,31 @@ module PostfixadminPlugin end + + # Efficiently list all Postfixadmin users belonging to the given + # Postfixadmin *domains*. + # + # @param domains [Array] the domains whose users we want. + # + # @return [Array] a list of {User} objects belonging to + # *domains* for this plugin. + # def list_domains_users(domains) usernames = [] + return usernames if domains.length() == 0 connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, @db_name, @db_user, @db_pass) - sql_query = 'SELECT username FROM mailbox WHERE domain IN $1;' + # The number of parameters that we'll pass into our prepared query + # is the number of domains that we're given. It's important that + # we have at least one domain here. + params = 1.upto(domains.length()).map{ |i| '$' + i.to_s() }.join(',') + sql_query = "SELECT username FROM mailbox WHERE domain IN (#{params});" - connection.query(sql_query, domains.map{|d| d.to_s()}) do |result| + # Now replace each Domain with its string representation and pass + # those in as our individual parameters. + connection.query(sql_query, domains.map{ |d| d.to_s() }) do |result| usernames = result.field_values('username') end @@ -73,10 +107,14 @@ module PostfixadminPlugin end + # Get a list of all Postfixadmin aliases as a from => to + # hash. This is useful for testing, since aliases should be removed + # when either the "from user" or "to user" are removed. + # + # @return [Hash] all aliases known to Postfixadmin in the form of a + # from => to hash. + # def list_aliases() - # - # Get a list of all aliases, useful for testing. - # aliases = [] connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, @@ -85,7 +123,8 @@ module PostfixadminPlugin sql_query = 'SELECT address,goto FROM alias;' results = connection.query(sql_query) results.each do |row| - aliases << row # row should be a hash + # row should be a hash + aliases << row end connection.close() @@ -93,4 +132,34 @@ module PostfixadminPlugin return aliases end + + # A fast implementation of the "does this domain exist?" + # operation. It only queries the database for the existence of + # *domain* rather than a list of all domains (which is the default + # implementation). + # + # @param domain [Domain] the domain whose existence is in question. + # + # @return [Boolean] true if *domain* exists in the Postfixadmin + # database and false otherwise. + # + def domain_exists(domain) + count = 0 + + connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, + @db_name, @db_user, @db_pass) + + sql_query = 'SELECT COUNT(domain) as count FROM domain WHERE domain = $1;' + connection.query(sql_query, [domain.to_s()]) do |result| + return false if result.ntuples() < 1 + count = result.getvalue(0,0).to_i() + + return false if count.nil? + end + + connection.close() + + return (count > 0) + end + end diff --git a/lib/common/roundcube_plugin.rb b/lib/common/roundcube_plugin.rb index 948a5b9..031e3f9 100644 --- a/lib/common/roundcube_plugin.rb +++ b/lib/common/roundcube_plugin.rb @@ -1,11 +1,19 @@ require 'common/plugin' require 'common/user' +# Code that all Roundcube plugins ({RoundcubePrune}, {RoundcubeRm}, +# {RoundcubeMv}) share. +# module RoundcubePlugin - # Code that all Roundcube plugins (Prune, Rm, Mv...) will share. - # That is, we implement the Plugin interface. + + # We implement the Plugin "interface." include Plugin + + # Initialize this Roundcube {Plugin} with values in *cfg*. + # + # @param cfg [Configuration] the configuration for this plugin. + # def initialize(cfg) @db_host = cfg.roundcube_dbhost @db_port = cfg.roundcube_dbport @@ -17,15 +25,25 @@ module RoundcubePlugin end + # Describe the given Roundcube *user*. + # + # @param user [User] the user whose description we want. + # + # @return [String] a string containing the Roundcube "User ID" + # associated with *user*. + # def describe_user(user) user_id = self.get_user_id(user) return "User ID: #{user_id}" end + # Return a list of Roundcube users. + # + # @return [Array] a list of users contained in the + # Roundcube database. + # def list_users() - # Produce a list of Roundcube users. This is used in prune/rm, and - # is public because it is useful in testing. usernames = [] connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, @@ -43,6 +61,13 @@ module RoundcubePlugin protected; + + # Find the Roundcube "User ID" associated with the given *user*. + # + # @param user [User] the user whose Roundcube "User ID" we want. + # + # @return [Fixnum] the Roundcube "User ID" for *user*. + # def get_user_id(user) user_id = nil diff --git a/lib/common/runner.rb b/lib/common/runner.rb index 01fcc17..2bb66e0 100644 --- a/lib/common/runner.rb +++ b/lib/common/runner.rb @@ -1,9 +1,30 @@ +# Methods inherited by the various runner classes ({PruneRunner}, +# {MvRunner}, {RmRunner}). +# module Runner - def run(cfg, plugin, targets) + + # The main thing a runner does is run(). Each runner will + # actually take a different number of arguments, so their + # run() signatures will differ. This stub is only here to + # let you know that it needs to be implemented. + # + # @param args [Array] whatever arguments the real implementation + # would take. + # + def run(*args) raise NotImplementedError end + + # Report a message from the given *plugin*. All this does is prefix + # the message with the plugin name and then print it to stdout. + # + # @param plugin [Object] t plugin object that generated the message + # we're reporting. + # + # @param msg [String] the message to report. + # def report(plugin, msg) print "#{plugin.class.to_s()} - " puts msg diff --git a/lib/common/user.rb b/lib/common/user.rb index f67d044..a5d3559 100644 --- a/lib/common/user.rb +++ b/lib/common/user.rb @@ -1,25 +1,46 @@ require 'common/domain' require 'common/errors' +# A class representing a syntactically valid user; that is, an email +# address. Once constructed, you can be sure that it's valid. +# class User - # A class representing a syntactically valid user; that is, an email - # address. Once constructed, you can be sure that it's valid. + # @localpart is a String containing the "user" part of "user@domain". @localpart = nil + + # @domain contains a {Domain} object representing the domain part of + # "user@domain". @domain = nil + + # Obtain the {Domain} object corresponding to this User. + # + # @return [Domain] the domain corresponding to this User. + # def domain() return @domain end + + # Obtain the domain part of this User object as a string. + # + # @return [String] a String representation of this User's domain. + # def domainpart() return @domain.to_s() end + + # Initialize this User object. If either of the local/domain parts + # is invalid, then either an {InvalidUserError} or an {InvalidDomainError} + # will be raised containing the reason why that part is invalid. + # + # @param username [String] an email address from which to construct + # this User. + # def initialize(username) - # Initialize this user object, but throw an error if either the - # localpart or domainpart are invalid. The one argument is an - # email address string. + if not username.is_a?(String) msg = 'username must be a String ' msg += "but a #{username.class.to_s()} was given" @@ -49,18 +70,50 @@ class User @domain = Domain.new(parts[1]) end + + # Obtain the "user" part of this User object as a String. + # + # @return [String] the "user" part of this User's "user@domain" + # address. + # def localpart() return @localpart end + + # Convert this User to an email address string. + # + # @return [String] an email address String constructed from this + # user's local and domain parts. + # def to_s() return @localpart + '@' + @domain.to_s() end + + # Check if this User is equal to some other User. The comparison + # is based on their String representations. + # + # @param other [User] the User object to compare me to. + # + # @return [Boolean] If *self* and *other* have equal String + # representations, then true is returned. Otherwise, false is + # returned. + # def ==(other) return self.to_s() == other.to_s() end + + # Compare two User objects for sorting purposes. The comparison is + # is based on their String representations. + # + # @param other [User] the User object to compare me to. + # + # @return [0,1,-1] a trinary indicator of how *self* relates to *other*, + # obtained by performing the same comparison on the String + # representations of *self* and *other*. + # def <=>(other) return self.to_s() <=> other.to_s() end diff --git a/lib/common/user_interface.rb b/lib/common/user_interface.rb index 6a60d04..00d3d7e 100644 --- a/lib/common/user_interface.rb +++ b/lib/common/user_interface.rb @@ -1,19 +1,51 @@ -def make_header(program_name, plugin_name) - # The header that we output before the list of domains/users. - # Just the path of this script, the current time, and the plugin name. - header = "#{program_name}, " - - current_time = Time.now() - if current_time.respond_to?(:iso8601) - # Somehow this method is missing on some machines. - header += current_time.iso8601.to_s - else - # Fall back to whatever this looks like. - header += current_time.to_s +# Class methods for the creation and manipulation of our command-line +# user interface. +# +class UserInterface + + # Construct a usage string showing how to invoke the program. + # + # @param program_name [String] the name of this program, used to + # construct the usage string. + # + # @return [String] a string showing the format of a correct program + # invocation. + # + def self.usage(program_name) + return "#{program_name} [prune | rm | mv ]" + end + + + # Construct the header that precedes our other output. An example is, + # + # mailshears, 2015-11-06 09:57:06 -0500 (Plugin: PrunePlugin) + # ------------------------------------------------------------ + # + # @param program_name [String] the name of this program, to appear + # in the header. + # + # @param plugin_name [String] the name of the mode (prune, mv, etc.) + # plugin that is being run. + # + # @return [String] a string containing the output header. + # + def self.make_header(program_name, plugin_name) + header = "#{program_name}, " + + current_time = Time.now() + if current_time.respond_to?(:iso8601) + # Somehow this method is missing on some machines. + header += current_time.iso8601.to_s() + else + # Fall back to whatever this looks like. + header += current_time.to_s() + end + + header += ' (Plugin: ' + plugin_name + ")\n" + header += '-' * header.size() # Underline the header. + + return header end - header += ' (Plugin: ' + plugin_name + ")\n" - header += '-' * header.size # Underline the header. - return header end diff --git a/lib/mv/mv_dummy_runner.rb b/lib/mv/mv_dummy_runner.rb index 7db7c06..9ed655a 100644 --- a/lib/mv/mv_dummy_runner.rb +++ b/lib/mv/mv_dummy_runner.rb @@ -1,8 +1,26 @@ require 'common/runner' +# Dummy implementation of a {MvRunner}. Its run() method will +# tell you what would have been moved, but will not actually perform +# the operation. +# class MvDummyRunner include Runner + # Pretend to move *src* to *dst* with *plugin*. Some "what if" + # information will be output to stdout. This is useful to see if + # there would be (for example) a username collision at *dst* before + # attempting the move in earnest. + # + # @param cfg [Configuration] the configuration options to pass to + # the *plugin* we're runnning. + # + # @param plugin [Class] plugin class that will perform the move. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user, to which we will move *src*. + # def run(cfg, plugin, src, dst) if src.is_a?(Domain) or dst.is_a?(Domain) then diff --git a/lib/mv/mv_plugin.rb b/lib/mv/mv_plugin.rb index 089e6c7..5a31923 100644 --- a/lib/mv/mv_plugin.rb +++ b/lib/mv/mv_plugin.rb @@ -1,20 +1,39 @@ +require 'common/plugin.rb' + +# Plugins for moving (renaming) users. Moving domains is not supported. +# module MvPlugin - # - # Plugins for moving (renaming) users. - # + # Absorb the subclass run() magic from the Plugin::Run module. extend Plugin::Run + # The runner class associated with move plugins. + # + # @return [Class] the {MvRunner} class. + # def self.runner() return MvRunner end + + # The "dummy" runner class associated with move plugins. + # + # @return [Class] the {MvDummyRunner} class. + # def self.dummy_runner() return MvDummyRunner end + + # The interface for the "move a user" operation. Subclasses need to + # implement this method so that it moves (renames) the user *src* to + # the user *dst*. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user to which we'll move *src*. + # def mv_user(src, dst) - # Rename the given user. raise NotImplementedError end diff --git a/lib/mv/mv_runner.rb b/lib/mv/mv_runner.rb index 0e5e909..fe44e5c 100644 --- a/lib/mv/mv_runner.rb +++ b/lib/mv/mv_runner.rb @@ -2,9 +2,23 @@ require 'common/domain' require 'common/errors' require 'common/runner' +# Perform the moving (renaming) of users/domains using {MvPlugin}s. +# class MvRunner include Runner + # Run *plugin* to move the user *src* to *dst*. The method + # signature includes the unused *cfg* for consistency with the + # runners that do need a {Configuration}. + # + # @param cfg [Configuration] unused. + # + # @param plugin [Class] plugin class that will perform the move. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user being moved to. + # def run(cfg, plugin, src, dst) if src.is_a?(Domain) or dst.is_a?(Domain) then diff --git a/lib/mv/plugins/agendav.rb b/lib/mv/plugins/agendav.rb index ad1e9c2..80ab1b6 100644 --- a/lib/mv/plugins/agendav.rb +++ b/lib/mv/plugins/agendav.rb @@ -3,18 +3,29 @@ require 'pg' require 'common/agendav_plugin' require 'mv/mv_plugin' + +# Handle moving (renaming) Agendav users in its database. Agendav has +# no concept of domains. +# class AgendavMv include AgendavPlugin include MvPlugin + # Move the user *src* to *dst* within the Agendav database. This + # should "rename" him in _every_ table where he is referenced. + # + # This can fail is *src* does not exist, or if *dst* already exists + # before the move. It should also be an error if the destination + # domain doesn't exist. But Agendav doesn't know about domains, so + # we let that slide. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user being moved to. + # def mv_user(src, dst) - # It's obviously an error if the source user does not exist. It - # would also be an error if the destination domain didn't exist; - # however, Agendav doesn't know about domains, so we let that slide. raise NonexistentUserError.new(src.to_s()) if not user_exists(src) - - # And it's an error if the destination user exists already. raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst) sql_queries = ['UPDATE prefs SET username = $1 WHERE username = $2;'] diff --git a/lib/mv/plugins/davical.rb b/lib/mv/plugins/davical.rb index 20c8a32..58287b3 100644 --- a/lib/mv/plugins/davical.rb +++ b/lib/mv/plugins/davical.rb @@ -3,26 +3,30 @@ require 'pg' require 'common/davical_plugin' require 'rm/rm_plugin' +# Handle moving (renaming) DAViCal users in its database. DAViCal has +# no concept of domains. +# class DavicalMv - # - # DAViCal only supports Postgres, so even if we ever are - # database-agnostic, this plugin can't be. - # include DavicalPlugin include MvPlugin + # Move the user *src* to *dst* within the DAViCal database. This + # should "rename" him in _every_ table where he is referenced. + # DAViCal uses foreign keys properly, so we let the ON UPDATE + # CASCADE trigger handle most of the work. + # + # This can fail is *src* does not exist, or if *dst* already exists + # before the move. It should also be an error if the destination + # domain doesn't exist. But DAViCal doesn't know about domains, so + # we let that slide. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user being moved to. + # def mv_user(src, dst) - # Switch the given usernames. DAViCal uses foreign keys properly - # and only supports postgres, so we let the ON UPDATE CASCADE - # trigger handle most of the work. - - # It's obviously an error if the source user does not exist. It - # would also be an error if the destination domain didn't exist; - # however, DAViCal doesn't know about domains, so we let that slide. raise NonexistentUserError.new(src.to_s()) if not user_exists(src) - - # And it's an error if the destination user exists already. raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst) sql_queries = ['UPDATE usr SET username = $1 WHERE username = $2'] diff --git a/lib/mv/plugins/dovecot.rb b/lib/mv/plugins/dovecot.rb index bd3084e..78219a2 100644 --- a/lib/mv/plugins/dovecot.rb +++ b/lib/mv/plugins/dovecot.rb @@ -4,34 +4,47 @@ require 'common/filesystem' require 'common/dovecot_plugin' require 'mv/mv_plugin' + +# Handle moving (renaming) Dovecot users on the filesystem. +# class DovecotMv include DovecotPlugin include MvPlugin + + # Move the Dovecot user *src* to *dst*. This relocates the user's + # directory within the Dovecot mailstore (on the filesystem). + # + # This fails if the source user does not exist, or if the + # destination user already exists before the move. + # + # But is it an error if the target domain does not exist? That's a + # bit subtle... The domain may exist in the database, but if it + # has not received any mail yet, then its directory won't exist + # on-disk. + # + # There are two possible "oops" scenarios resulting from the fact + # that we may run either the Postfixadmin move first or the + # Dovecot move first. If we move the user in the database, we + # definitely want to move him on disk (that is, we should create + # the directory here). But if we move him on disk first, then we + # don't know if the database move will fail! We don't want to move + # his mail files if he won't get moved in the database. + # + # Faced with two equally-bad (but easy-to-fix) options, we do the + # simplest thing and fail if the destination domain directory + # doesn't exist. If nothing else, this is at least consistent. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user being moved to. + # def mv_user(src, dst) - # It's obviously an error if the source user does not exist. raise NonexistentUserError.new(src.to_s()) if not user_exists(src) - - # And it's an error if the destination user exists already. raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst) - # But is it an error if the target domain does not exist? That's a - # bit subtle... The domain may exist in the database, but if it - # has not received any mail yet, then its directory won't exist - # on-disk. - # - # There are two possible "oops" scenarios resulting from the fact - # that we may run either the Postfixadmin move first or the - # Dovecot move first. If we move the user in the database, we - # definitely want to move him on disk (that is, we should create - # the directory here). But if we move him on disk first, then we - # don't know if the database move will fail! We don't want to move - # his mail files if he won't get moved in the database. - # - # Faced with two equally-bad (but easy-to-fix) options, we do the - # simplest thing and fail if the destination domain directory - # doesn't exist. If nothing else, this is at least consistent. + # See the docstring... if not self.domain_exists(dst.domain()) then raise NonexistentDomainError.new(dst.domainpart()) end diff --git a/lib/mv/plugins/postfixadmin.rb b/lib/mv/plugins/postfixadmin.rb index 9317b4b..e22df6a 100644 --- a/lib/mv/plugins/postfixadmin.rb +++ b/lib/mv/plugins/postfixadmin.rb @@ -3,22 +3,34 @@ require 'pg' require 'common/postfixadmin_plugin' require 'mv/mv_plugin' + +# Handle moving (renaming) of users in the Postfixadmin database. +# class PostfixadminMv include PostfixadminPlugin include MvPlugin + + # Move the user *src* to *dst* within the Postfixadmin + # database. This should "rename" him in _every_ table where he is + # referenced. Unfortunately that must be done manually. + # + # This can fail is *src* does not exist, or if *dst* already exists + # before the move. It will also fail if the domain associated with + # the user *dst* does not exist. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user being moved to. + # def mv_user(src, dst) - # It's obviously an error if the source user does not exist. raise NonexistentUserError.new(src.to_s()) if not user_exists(src) - # And if the destination domain does not exist. if not domain_exists(dst.domain()) raise NonexistentDomainError.new(dst.domain.to_s()) end - # But the destination *user* should *not* exist. - # And it's an error if the destination user exists already. raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst) mailbox_query = 'UPDATE mailbox SET ' diff --git a/lib/mv/plugins/roundcube.rb b/lib/mv/plugins/roundcube.rb index 7f345a4..4d81964 100644 --- a/lib/mv/plugins/roundcube.rb +++ b/lib/mv/plugins/roundcube.rb @@ -3,19 +3,31 @@ require 'pg' require 'common/roundcube_plugin' require 'mv/mv_plugin' +# Handle moving (renaming) of users in the Roundcube +# database. Roundcube has no concept of domains. +# class RoundcubeMv include RoundcubePlugin include MvPlugin + # Move the user *src* to *dst* within the Roundcube database. This + # should "rename" him in _every_ table where he is referenced. + # Roundcube uses foreign keys properly, so we let the ON UPDATE + # CASCADE trigger handle most of the work. + # + # This can fail is *src* does not exist, or if *dst* already exists + # before the move. It should also be an error if the destination + # domain doesn't exist. But Roundcube doesn't know about domains, so + # we let that slide. + # + # @param src [User] the source user to be moved. + # + # @param dst [User] the destination user being moved to. + # def mv_user(src, dst) - # It's obviously an error if the source user does not exist. It - # would also be an error if the destination domain didn't exist; - # however, Roundcube doesn't know about domains, so we let that slide. raise NonexistentUserError.new(src.to_s()) if not user_exists(src) - - # And it's an error if the destination user exists already. raise UserAlreadyExistsError.new(dst.to_s()) if user_exists(dst) sql_queries = ['UPDATE users SET username = $1 WHERE username = $2;'] diff --git a/lib/prune/plugins/agendav.rb b/lib/prune/plugins/agendav.rb index 86a437b..f0f0183 100644 --- a/lib/prune/plugins/agendav.rb +++ b/lib/prune/plugins/agendav.rb @@ -3,6 +3,11 @@ require 'pg' require 'prune/prune_plugin' require 'rm/plugins/agendav' +# Handle the pruning of Agendav users from its database. This class +# doesn't need to do anything; by inheriting from {AgendavRm}, we get +# its {AgendavRm#remove_user} method and that's all we need to prune. +# class AgendavPrune < AgendavRm + # Needed for the magic includers run() method. include PrunePlugin end diff --git a/lib/prune/plugins/davical.rb b/lib/prune/plugins/davical.rb index f7f4b39..743356d 100644 --- a/lib/prune/plugins/davical.rb +++ b/lib/prune/plugins/davical.rb @@ -3,6 +3,11 @@ require 'pg' require 'prune/prune_plugin' require 'rm/plugins/davical' +# Handle the pruning of DAViCal users from its database. This class +# doesn't need to do anything; by inheriting from {DavicalRm}, we get +# its {DavicalRm#remove_user} method and that's all we need to prune. +# class DavicalPrune < DavicalRm + # Needed for the magic includers run() method. include PrunePlugin end diff --git a/lib/prune/plugins/dovecot.rb b/lib/prune/plugins/dovecot.rb index 7d346ac..9fbeb58 100644 --- a/lib/prune/plugins/dovecot.rb +++ b/lib/prune/plugins/dovecot.rb @@ -1,6 +1,11 @@ require 'prune/prune_plugin' require 'rm/plugins/dovecot' +# Handle the pruning of Dovecot users from its database. This class +# doesn't need to do anything; by inheriting from {DovecotRm}, we get +# its {DovecotRm#remove_user} method and that's all we need to prune. +# class DovecotPrune < DovecotRm + # Needed for the magic includers run() method. include PrunePlugin end diff --git a/lib/prune/plugins/postfixadmin.rb b/lib/prune/plugins/postfixadmin.rb index 395fb24..7059e07 100644 --- a/lib/prune/plugins/postfixadmin.rb +++ b/lib/prune/plugins/postfixadmin.rb @@ -1,10 +1,13 @@ require 'prune/prune_plugin' require 'rm/plugins/postfixadmin' -class PostfixadminPrune < PostfixadminRm - # We don't need the ability to remove "left over" users or - # domains, since "left over" is with respect to what's in - # PostfixAdmin. In other words, the other plugins check themselves - # against PostfixAdmin, it doesn't make sense to check PostfixAdmin - # against itself. -end +# This class does absolutely nothing except allow us to blindly +# instantiate classes based on the mode name and configured plugins. +# +# We don't need the ability to remove "left over" Postfixadmin users +# or domains, since "left over" is with respect to what's in +# Postfixadmin itself. The other pruning plugins check themselves +# against Postfixadmin -- it doesn't make sense to check Postfixadmin +# against itself. +# +class PostfixadminPrune < PostfixadminRm; end diff --git a/lib/prune/plugins/roundcube.rb b/lib/prune/plugins/roundcube.rb index 5869b02..9297392 100644 --- a/lib/prune/plugins/roundcube.rb +++ b/lib/prune/plugins/roundcube.rb @@ -3,6 +3,12 @@ require 'pg' require 'prune/prune_plugin' require 'rm/plugins/roundcube' + +# Handle the pruning of Roundcube users from its database. This class +# doesn't need to do anything; by inheriting from {RoundcubeRm}, we get +# its {RoundcubeRm#remove_user} method and that's all we need to prune. +# class RoundcubePrune < RoundcubeRm + # Needed for the magic includers run() method. include PrunePlugin end diff --git a/lib/prune/prune_dummy_runner.rb b/lib/prune/prune_dummy_runner.rb index d488f7e..f90c9d0 100644 --- a/lib/prune/prune_dummy_runner.rb +++ b/lib/prune/prune_dummy_runner.rb @@ -2,9 +2,29 @@ require 'common/runner' require 'prune/plugins/postfixadmin' require 'rm/rm_dummy_runner' +# Dummy implementation of a {PruneRunner}. Its run() method will +# tell you what would have been pruned, but will not actually perform +# the operation. +# class PruneDummyRunner include Runner + + # Pretend to prune unused domains and users. Some "what if" + # information will be output to stdout. + # + # The prune mode is the main application of the "dummy" runners, + # since it performs some computation outside of the plugins + # themselves. This lets the user know which users and domains would + # be removed and can help prevent mistakes or even find bugs in the + # prune code, if it looks like something will be removed that + # shouldn't be! + # + # @param cfg [Configuration] the configuration options to pass to + # the *plugin* we're runnning. + # + # @param plugin [Class] plugin class that will do the pruning. + # def run(cfg, plugin) # We don't want to check the PostfixAdmin database against itself. return if plugin.class == PostfixadminPrune diff --git a/lib/prune/prune_plugin.rb b/lib/prune/prune_plugin.rb index 4757c02..df621b8 100644 --- a/lib/prune/prune_plugin.rb +++ b/lib/prune/prune_plugin.rb @@ -1,33 +1,65 @@ -require 'rm/rm_plugin' +require 'common/plugin.rb' +# Plugins for pruning users. By "pruning," we mean the removal of +# leftover non-PostfixAdmin users after the associated user has been +# removed from the Postfixadmin database. +# module PrunePlugin - # - # Plugins for the removal of leftover non-PostfixAdmin users, - # i.e. after an user has been removed from the PostfixAdmin - # database. - # + + # Absorb the subclass run() magic from the Plugin::Run module. extend Plugin::Run + # The runner class associated with pruning plugins. + # + # @return [Class] the {PruneRunner} class. + # def self.runner() return PruneRunner end + + # The "dummy" runner class associated with pruning plugins. + # + # @return [Class] the {PruneDummyRunner} class. + # def self.dummy_runner return PruneDummyRunner end - def get_leftover_domains(db_domains) - # Given a list of domains, determine which domains belonging to - # this plugin are not contained in the given list. + # Determine which domains are "left over" for this plugin. A domain + # is considered "left over" if it has been removed from Postfixadmin + # but not some other plugin. + # + # The leftovers are determined with respect to the list *db_domains* + # of domains that Postfixadmin knows about. + # + # @param db_domains [Array] a list of domains that are present + # in the Postfixadmin database. + # + # @return [Array] a list of domains known to this plugin but + # not to Postfixadmin. + # + def get_leftover_domains(db_domains) # WARNING! Array difference doesn't work for some reason. return list_domains().select{ |d| !db_domains.include?(d) } end - def get_leftover_users(db_users) - # Given a list of users, determine which users belonging to - # this plugin are not contained in the given list. + # Determine which users are "left over" for this plugin. A user + # is considered "left over" if it has been removed from Postfixadmin + # but not some other plugin. + # + # The leftovers are determined with respect to the list *db_users* + # of users that Postfixadmin knows about. + # + # @param db_users [Array] a list of users that are present + # in the Postfixadmin database. + # + # @return [Array] a list of users known to this plugin but + # not to Postfixadmin. + # + def get_leftover_users(db_users) # WARNING! Array difference doesn't work for some reason. return list_users().select{ |u| !db_users.include?(u) } end diff --git a/lib/prune/prune_runner.rb b/lib/prune/prune_runner.rb index fbad8e8..a9e2282 100644 --- a/lib/prune/prune_runner.rb +++ b/lib/prune/prune_runner.rb @@ -2,9 +2,18 @@ require 'common/runner' require 'prune/plugins/postfixadmin' require 'rm/rm_runner' +# Perform the pruning of users/domains using {PrunePlugin}s. +# class PruneRunner include Runner + # Run *plugin* to prune leftover users and directories. + # + # @param cfg [Configuration] configuration options passed to + # {PostfixadminPrune}. + # + # @param plugin [Class] plugin class that will perform the pruning. + # def run(cfg, plugin) # We don't want to check the PostfixAdmin database against itself. return if plugin.class == PostfixadminPrune @@ -17,6 +26,7 @@ class PruneRunner leftovers = plugin.get_leftover_users(db_users) leftovers += plugin.get_leftover_domains(db_domains) + # We're counting on our PrunePlugin also being an RmPlugin here. rm_runner = RmRunner.new() rm_runner.run(cfg, plugin, *leftovers) end diff --git a/lib/rm/plugins/agendav.rb b/lib/rm/plugins/agendav.rb index 6d92c26..28f1eb4 100644 --- a/lib/rm/plugins/agendav.rb +++ b/lib/rm/plugins/agendav.rb @@ -3,15 +3,22 @@ require 'pg' require 'common/agendav_plugin' require 'rm/rm_plugin' + +# Handle the removal of Agendav users from its database. Agendav has +# no concept of domains. +# class AgendavRm include AgendavPlugin include RmPlugin - def delete_user(user) - # Delete the given username and any records in other tables - # belonging to it. + # Remove *user* from the Agendav database. This should remove him + # from _every_ table in which he is referenced. + # + # @param user [User] the user to remove. + # + def remove_user(user) raise NonexistentUserError.new(user.to_s()) if not user_exists(user) sql_queries = ['DELETE FROM prefs WHERE username = $1;'] diff --git a/lib/rm/plugins/davical.rb b/lib/rm/plugins/davical.rb index 77b06b4..852d9c4 100644 --- a/lib/rm/plugins/davical.rb +++ b/lib/rm/plugins/davical.rb @@ -3,19 +3,24 @@ require 'pg' require 'common/davical_plugin' require 'rm/rm_plugin' +# Handle the removal of DAViCal users from its database. DAViCal has +# no concept of domains. +# class DavicalRm - # - # DAViCal only supports Postgres, so even if we ever are - # database-agnostic, this plugin can't be. - # + include DavicalPlugin include RmPlugin - def delete_user(user) - # Delete the given username. DAViCal uses foreign keys properly - # and only supports postgres, so we let the ON DELETE CASCADE - # trigger handle most of the work. + # Remove *user* from the DAViCal database. This should remove him + # from _every_ table in which he is referenced. Fortunately, DAViCal + # uses foreign keys properly (and only supports postgres, where they + # work!), so we can let the ON DELETE CASCADE trigger handle most of + # the work. + # + # @param user [User] the user to remove. + # + def remove_user(user) raise NonexistentUserError.new(user.to_s()) if not user_exists(user) sql_queries = ['DELETE FROM usr WHERE username = $1'] diff --git a/lib/rm/plugins/dovecot.rb b/lib/rm/plugins/dovecot.rb index 0ced5be..7babfbe 100644 --- a/lib/rm/plugins/dovecot.rb +++ b/lib/rm/plugins/dovecot.rb @@ -1,16 +1,24 @@ -# Needed for rm_r. require 'fileutils' require 'common/dovecot_plugin' require 'rm/rm_plugin' + +# Handle the removal of users and domains from the Dovecot mailstore +# (the filesystem). +# class DovecotRm include DovecotPlugin include RmPlugin - def delete_domain(domain) + # Remove *domain* from the Dovecot mailstore. This just runs "rm -r" + # on the domain directory if it exists. + # + # @param domain [Domain] the domain to remove. + # + def remove_domain(domain) domain_path = self.get_domain_path(domain) if not File.directory?(domain_path) @@ -20,7 +28,14 @@ class DovecotRm FileUtils.rm_r(domain_path) end - def delete_user(user) + + # Remove *user* from the Dovecot mailstore. This just runs "rm -r" + # on the *user*'s mailbox directory, if it exists. + # + # @param user [User] the user whose mailbox directory we want to + # remove. + # + def remove_user(user) user_path = self.get_user_path(user) if not File.directory?(user_path) diff --git a/lib/rm/plugins/postfixadmin.rb b/lib/rm/plugins/postfixadmin.rb index 103912b..b950b7a 100644 --- a/lib/rm/plugins/postfixadmin.rb +++ b/lib/rm/plugins/postfixadmin.rb @@ -3,18 +3,39 @@ require 'pg' require 'common/postfixadmin_plugin' require 'rm/rm_plugin' + +# Handle the removal of users and domains from the Postfixadmin database. +# class PostfixadminRm include PostfixadminPlugin include RmPlugin - def delete_user(user) + # Remove *user* from the Postfixadmin database. This should remove + # him from _every_ table in which he is referenced. Unfortunately, + # Postfixadmin does not use foreign keys or ON DELETE CASCADE + # triggers so we need to delete the associated child table records + # ourselves. + # + # @param user [User] the user to remove. + # + def remove_user(user) raise NonexistentUserError.new(user.to_s()) if not user_exists(user) + # Remove aliases FROM our user to some other address. sql_queries = ['DELETE FROM alias WHERE address = $1;'] - # Wipe out any aliases pointed at our user. + + # Also delete aliases that point SOLELY TO our user. + sql_queries << "DELETE FROM alias WHERE goto = $1;" + + # But aliases don't need to point to a single user! If our user + # was part of a multi-recipient alias, we want to remove our user + # from the alias and leave the other recipients. If you're + # wondering about the leftover double-commas, look towards the end + # of the function. sql_queries << "UPDATE alias SET goto=REPLACE(goto, $1, '');" + sql_queries << 'DELETE FROM mailbox WHERE username = $1;' sql_queries << 'DELETE FROM quota WHERE username = $1;' sql_queries << 'DELETE FROM quota2 WHERE username = $1;' @@ -41,14 +62,37 @@ class PostfixadminRm end - def delete_domain(domain) + # Remove *domain* from the Postfixadmin database. This should remove + # the domain from _every_ table in which it is referenced. It should + # also remove every user that belongs to the doomed domain + # Postfixadmin has some experimental support for triggers, but they + # don't do a very good job of cleaning up. Therefore we remove all + # users in the domain manually before removing the domain itself. + # + # Log entries (from the "log" table) are not removed since they may + # still contain valuable information (although they won't mention + # this removal). + # + # @param domain [Domain] the domain to remove. + # + def remove_domain(domain) raise NonexistentDomainError.new(domain.to_s()) if not domain_exists(domain) + # First remove all users belonging to the domain. This will handle + # alias updates and all the sensitive crap we need to do when + # removing a user. + users = list_domains_users([domain]) + users.each { |u| remove_user(u) } + + # The domain_admins table contains one record per domain + # (repeating the user as necessary), so this really is sufficient. sql_queries = ['DELETE FROM domain_admins WHERE domain = $1;'] + + # Some of the following queries should be redundant now that we've + # removed all users in the domain. sql_queries << 'DELETE FROM alias WHERE domain = $1;' sql_queries << 'DELETE FROM mailbox WHERE domain = $1;' sql_queries << 'DELETE FROM alias_domain WHERE alias_domain = $1;' - sql_queries << 'DELETE FROM log WHERE domain = $1;' sql_queries << 'DELETE FROM vacation WHERE domain = $1;' sql_queries << 'DELETE FROM domain WHERE domain = $1;' @@ -62,29 +106,4 @@ class PostfixadminRm connection.close() end - - protected; - - def domain_exists(domain) - count = 0 - - connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, - @db_name, @db_user, @db_pass) - - sql_query = 'SELECT COUNT(domain) as count FROM domain WHERE domain = $1;' - connection.query(sql_query, [domain.to_s()]) do |result| - return false if result.ntuples() < 1 - begin - count = result.getvalue(0,0).to_i() - return false if count.nil? - rescue StandardError - return false - end - end - - connection.close() - - return (count > 0) - end - end diff --git a/lib/rm/plugins/roundcube.rb b/lib/rm/plugins/roundcube.rb index c25874e..88e88c4 100644 --- a/lib/rm/plugins/roundcube.rb +++ b/lib/rm/plugins/roundcube.rb @@ -3,27 +3,30 @@ require 'pg' require 'common/roundcube_plugin' require 'rm/rm_plugin' +# Handle removal of Roundcube users from its database. Roundcube has +# no concept of domains. +# class RoundcubeRm include RoundcubePlugin include RmPlugin - def delete_user(user) - # Delete the given username and any records in other tables - # belonging to it. + # Remove *user* from the Roundcube database. This should remove him + # from _every_ table in which he is referenced. Fortunately the + # Roundcube developers were nice enough to include DBMS-specific + # install and upgrade scripts, so Postgres can take advantage of ON + # DELETE triggers. + # + # @param user [User] the user to remove. + # + def remove_user(user) raise NonexistentUserError.new(user.to_s()) if not user_exists(user) + # Get the primary key for this user in the "users" table. user_id = self.get_user_id(user) - # The Roundcube developers were nice enough to include - # DBMS-specific install and upgrade scripts, so Postgres can take - # advantage of ON DELETE triggers. Here's an example: - # - # ... - # user_id integer NOT NULL - # REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE - # - # This query is of course necessary with any DBMS: + # Thanks to the ON DELETE triggers, this will remove all child + # records associated with user_id too. sql_queries = ['DELETE FROM users WHERE user_id = $1::int;'] connection = PGconn.connect(@db_host, @db_port, @db_opts, @db_tty, diff --git a/lib/rm/rm_dummy_runner.rb b/lib/rm/rm_dummy_runner.rb index 62d9b62..54a8969 100644 --- a/lib/rm/rm_dummy_runner.rb +++ b/lib/rm/rm_dummy_runner.rb @@ -1,8 +1,29 @@ require 'common/runner' +# Dummy implementation of a {RmRunner}. Its run() method will +# tell you what would have been removed, but will not actually perform +# the operation. +# class RmDummyRunner include Runner + + # Pretend to remove *targets*. Some "what if" + # information will be output to stdout. + # + # This dummy runner is not particularly useful on its own. About the + # only thing it does is let you know that the users/domains in + # *targets* do in fact exist (through their descriptions). It's used + # to good effect by {PruneDummyRunner}, though. + # + # @param cfg [Configuration] the configuration options to pass to + # the *plugin* we're runnning. + # + # @param plugin [RmPlugin] plugin that will perform the move. + # + # @param targets [Array] the users and domains to be + # removed. + # def run(cfg, plugin, *targets) targets.each do |target| target_description = plugin.describe(target) diff --git a/lib/rm/rm_plugin.rb b/lib/rm/rm_plugin.rb index fd0c541..ff165b4 100644 --- a/lib/rm/rm_plugin.rb +++ b/lib/rm/rm_plugin.rb @@ -1,47 +1,76 @@ require 'common/plugin.rb' +# +# Plugins for the removal of users and domains. +# module RmPlugin - # - # Plugins for the removal of users. - # + # Absorb the subclass run() magic from the Plugin::Run module. extend Plugin::Run + # The runner class associated with removal plugins. + # + # @return [Class] the {RmRunner} class. + # def self.runner() return RmRunner end + + # The "dummy" runner class associated with removal plugins. + # + # @return [Class] the {RmDummyRunner} class. + # def self.dummy_runner() return RmDummyRunner end - def delete(target) - # A generic version of delete_user/delete_domain that + + # Remove the *target* domain or user. This is a generic version of + # the {#remove_user} and {#remove_domain} operations that will + # dispatch based on the type of *target*. + # + # @param target [User,Domain] the user or domain to remove. + # + def remove(target) + # A generic version of remove_user/remove_domain that # dispatches base on the class of the target. if target.is_a?(User) - return delete_user(target) + return remove_user(target) elsif target.is_a?(Domain) - return delete_domain(target) + return remove_domain(target) else raise NotImplementedError end end - def delete_domain(domain) - # Delete the given domain. Some plugins don't have a concept of - # domains, so just delete all users with a username that looks - # like it's in the given domain. + + # Remove the given *domain*. Some plugins don't have a concept of + # domains, so the default implementation here removes all users that + # look like they belong to *domain*. Subclasses can be smarter. + # + # @param domain [Domain] the domain to remove. + # + def remove_domain(domain) users = list_domains_users([domain]) + # It's possible for a domain to exist with no users, but this + # default implementation is based on the assumption that it should + # work for plugins having no "domain" concept. raise NonexistentDomainError.new(domain.to_s()) if users.empty? users.each do |u| - delete_user(u) + remove_user(u) end end - def delete_user(user) - # Delete the given user. + + # The interface for the "remove a user" operation. Subclasses + # need to implement this method so that it removes *user*. + # + # @param user [User] the user to remove. + # + def remove_user(user) raise NotImplementedError end diff --git a/lib/rm/rm_runner.rb b/lib/rm/rm_runner.rb index a19004a..4162ddf 100644 --- a/lib/rm/rm_runner.rb +++ b/lib/rm/rm_runner.rb @@ -1,24 +1,44 @@ require 'common/errors' require 'common/runner' +# Perform the removal of users/domains using {RmPlugin}s. +# class RmRunner include Runner + # Run *plugin* to remove the users/domains in *targets*. The method + # signature includes the unused *cfg* for consistency with the + # runners that do need a {Configuration}. + # + # @param cfg [Configuration] unused. + # + # @param plugin [Class] plugin class that will perform the removal. + # + # @param targets [Array] the users and domains to be + # removed. + # def run(cfg, plugin, *targets) targets.each do |target| remove_target(plugin, target) end end - private; + protected; + + # Remove *target* using *plugin*. This operation is separate from + # the run() method so that it can be accessed by the prune + # runner. + # + # @param plugin [RmPlugin] the plugin that will remove the *target*. + # + # @param target [User,Domain] the user or domain to remove. + # def remove_target(plugin, target) - # Wrap the "remove this thing" operation so that it can be reused - # in the prine plugin. target_description = plugin.describe(target) begin - plugin.delete(target) + plugin.remove(target) msg = "Removed #{target.class.to_s().downcase()} #{target}" # Only append the extra description if it's useful. -- 2.44.2