diff --git a/README.rst b/README.rst index d36ff59..6fddac5 100644 --- a/README.rst +++ b/README.rst @@ -42,6 +42,8 @@ The following components are installed by the installer: * Dovecot * Amavis (with SpamAssassin and ClamAV) * automx (autoconfiguration service) +* OpenDKIM +* Radicale (CalDAV and CardDAV server) If you want to customize configuration before running the installer, run the following command:: @@ -66,6 +68,22 @@ a previous one using the ``--version`` option:: If you want more information about the installation process, add the ``--debug`` option to your command line. +Upgrade mode +------------ + +An experimental upgrade mode is available. + +.. note:: + + You must keep the original configuration file, ie the one used for + the installation. Otherwise, you won't be able to use this mode. + +You can activate it as follows:: + + $ sudo ./run.py --upgrade + +It will automatically install latest versions of modoboa and its plugins. + Change the generated hostname ----------------------------- diff --git a/modoboa_installer/scripts/__init__.py b/modoboa_installer/scripts/__init__.py index 33af7d4..3edfa66 100644 --- a/modoboa_installer/scripts/__init__.py +++ b/modoboa_installer/scripts/__init__.py @@ -6,7 +6,7 @@ import sys from .. import utils -def install(appname, config): +def install(appname, config, upgrade): """Install an application.""" if (config.has_option(appname, "enabled") and not config.getboolean(appname, "enabled")): @@ -19,7 +19,7 @@ def install(appname, config): print("Unknown application {}".format(appname)) sys.exit(1) try: - getattr(script, appname.capitalize())(config).run() + getattr(script, appname.capitalize())(config, upgrade).run() except utils.FatalError as inst: utils.printcolor(u"{}".format(inst), utils.RED) sys.exit(1) diff --git a/modoboa_installer/scripts/amavis.py b/modoboa_installer/scripts/amavis.py index ed50ab5..87fbcb8 100644 --- a/modoboa_installer/scripts/amavis.py +++ b/modoboa_installer/scripts/amavis.py @@ -77,5 +77,5 @@ class Amavis(base.Installer): """Additional tasks.""" with open("/etc/mailname", "w") as fp: fp.write("{}\n".format(self.config.get("general", "hostname"))) - install("spamassassin", self.config) - install("clamav", self.config) + install("spamassassin", self.config, self.upgrade) + install("clamav", self.config, self.upgrade) diff --git a/modoboa_installer/scripts/automx.py b/modoboa_installer/scripts/automx.py index 2c9f5b0..8d259c8 100644 --- a/modoboa_installer/scripts/automx.py +++ b/modoboa_installer/scripts/automx.py @@ -24,11 +24,11 @@ class Automx(base.Installer): } with_user = True - def __init__(self, config): + def __init__(self, *args, **kwargs): """Get configuration.""" - super(Automx, self).__init__(config) - self.venv_path = config.get("automx", "venv_path") - self.instance_path = config.get("automx", "instance_path") + super(Automx, self).__init__(*args, **kwargs) + self.venv_path = self.config.get("automx", "venv_path") + self.instance_path = self.config.get("automx", "instance_path") def get_template_context(self): """Additional variables.""" diff --git a/modoboa_installer/scripts/base.py b/modoboa_installer/scripts/base.py index a0f084e..d3f861f 100644 --- a/modoboa_installer/scripts/base.py +++ b/modoboa_installer/scripts/base.py @@ -19,9 +19,10 @@ class Installer(object): with_db = False config_files = [] - def __init__(self, config): + def __init__(self, config, upgrade): """Get configuration.""" self.config = config + self.upgrade = upgrade if self.config.has_section(self.appname): self.app_config = dict(self.config.items(self.appname)) self.dbengine = self.config.get("database", "engine") @@ -67,8 +68,8 @@ class Installer(object): self.backend.load_sql_file( self.dbname, self.dbuser, self.dbpasswd, schema) - def create_user(self): - """Create a system user.""" + def setup_user(self): + """Setup a system user.""" if not self.with_user: return self.user = self.config.get(self.appname, "user") @@ -143,8 +144,9 @@ class Installer(object): def run(self): """Run the installer.""" self.install_packages() - self.create_user() - self.setup_database() + self.setup_user() + if not self.upgrade: + self.setup_database() self.install_config_files() self.post_run() self.restart_daemon() diff --git a/modoboa_installer/scripts/modoboa.py b/modoboa_installer/scripts/modoboa.py index a00a2c7..9632d99 100644 --- a/modoboa_installer/scripts/modoboa.py +++ b/modoboa_installer/scripts/modoboa.py @@ -36,13 +36,13 @@ class Modoboa(base.Installer): with_db = True with_user = True - def __init__(self, config): + def __init__(self, *args, **kwargs): """Get configuration.""" - super(Modoboa, self).__init__(config) - self.venv_path = config.get("modoboa", "venv_path") - self.instance_path = config.get("modoboa", "instance_path") - self.extensions = config.get("modoboa", "extensions").split() - self.devmode = config.getboolean("modoboa", "devmode") + super(Modoboa, self).__init__(*args, **kwargs) + self.venv_path = self.config.get("modoboa", "venv_path") + self.instance_path = self.config.get("modoboa", "instance_path") + self.extensions = self.config.get("modoboa", "extensions").split() + self.devmode = self.config.getboolean("modoboa", "devmode") # Sanity check for amavis self.amavis_enabled = False if "modoboa-amavis" in self.extensions: @@ -87,7 +87,8 @@ class Modoboa(base.Installer): packages.append(extension) # Temp fix for https://github.com/modoboa/modoboa-installer/issues/197 python.install_package( - modoboa_package, self.venv_path, binary=False, sudo_user=self.user) + modoboa_package, self.venv_path, + upgrade=self.upgrade, binary=False, sudo_user=self.user) if self.dbengine == "postgres": packages.append("psycopg2-binary") else: @@ -99,18 +100,23 @@ class Modoboa(base.Installer): # Temp. fix packages += [ "https://github.com/modoboa/caldav/tarball/master#egg=caldav"] - python.install_packages(packages, self.venv_path, sudo_user=self.user) + python.install_packages( + packages, self.venv_path, upgrade=self.upgrade, sudo_user=self.user) if self.devmode: # FIXME: use dev-requirements instead python.install_packages( ["django-bower", "django-debug-toolbar"], self.venv_path, - sudo_user=self.user) + upgrade=self.upgrade, sudo_user=self.user) def _deploy_instance(self): """Deploy Modoboa.""" target = os.path.join(self.home_dir, "instance") if os.path.exists(target): - if not self.config.getboolean("general", "force"): + condition = ( + not self.upgrade and + not self.config.getboolean("general", "force") + ) + if condition: utils.printcolor( "Target directory for Modoboa deployment ({}) already " "exists. If you choose to continue, it will be removed." @@ -239,4 +245,5 @@ class Modoboa(base.Installer): """Additional tasks.""" self._setup_venv() self._deploy_instance() - self.apply_settings() + if not self.upgrade: + self.apply_settings() diff --git a/modoboa_installer/scripts/nginx.py b/modoboa_installer/scripts/nginx.py index 4f7c4f3..bfa0850 100644 --- a/modoboa_installer/scripts/nginx.py +++ b/modoboa_installer/scripts/nginx.py @@ -25,7 +25,8 @@ class Nginx(base.Installer): context.update({ "app_instance_path": ( self.config.get(app, "instance_path")), - "uwsgi_socket_path": Uwsgi(self.config).get_socket_path(app) + "uwsgi_socket_path": ( + Uwsgi(self.config, self.upgrade).get_socket_path(app)) }) return context diff --git a/modoboa_installer/scripts/postfix.py b/modoboa_installer/scripts/postfix.py index b6c6955..d264100 100644 --- a/modoboa_installer/scripts/postfix.py +++ b/modoboa_installer/scripts/postfix.py @@ -97,4 +97,4 @@ class Postfix(base.Installer): utils.exec_cmd("postalias {}".format(aliases_file)) # Postwhite - install("postwhite", self.config) + install("postwhite", self.config, self.upgrade) diff --git a/modoboa_installer/scripts/radicale.py b/modoboa_installer/scripts/radicale.py index a65f1a7..c5a2dec 100644 --- a/modoboa_installer/scripts/radicale.py +++ b/modoboa_installer/scripts/radicale.py @@ -22,10 +22,10 @@ class Radicale(base.Installer): } with_user = True - def __init__(self, config): + def __init__(self, *args, **kwargs): """Get configuration.""" - super(Radicale, self).__init__(config) - self.venv_path = config.get("radicale", "venv_path") + super(Radicale, self).__init__(*args, **kwargs) + self.venv_path = self.config.get("radicale", "venv_path") def _setup_venv(self): """Prepare a dedicated virtualenv.""" diff --git a/modoboa_installer/scripts/spamassassin.py b/modoboa_installer/scripts/spamassassin.py index 7b14f5f..49186f4 100644 --- a/modoboa_installer/scripts/spamassassin.py +++ b/modoboa_installer/scripts/spamassassin.py @@ -61,7 +61,7 @@ class Spamassassin(base.Installer): "pyzor --homedir {} discover".format(pw[5]), sudo_user=amavis_user, login=False ) - install("razor", self.config) + install("razor", self.config, self.upgrade) if utils.dist_name() in ["debian", "ubuntu"]: utils.exec_cmd( "perl -pi -e 's/^CRON=0/CRON=1/' /etc/cron.daily/spamassassin") diff --git a/modoboa_installer/ssl.py b/modoboa_installer/ssl.py index 5c92fcf..6073945 100644 --- a/modoboa_installer/ssl.py +++ b/modoboa_installer/ssl.py @@ -43,7 +43,7 @@ class SelfSignedCertificate(CertificateBackend): return raise RuntimeError("Cannot find a directory to store certificate") - def create(self): + def generate_cert(self): """Create a certificate.""" if not self.overwrite_existing_certificate(): return @@ -61,26 +61,30 @@ class SelfSignedCertificate(CertificateBackend): class LetsEncryptCertificate(CertificateBackend): """Create a certificate using letsencrypt.""" - def create(self): + def __init__(self, *args, **kwargs): + """Update config.""" + super(LetsEncryptCertificate, self).__init__(*args, **kwargs) + self.hostname = self.config.get("general", "hostname") + self.config.set("general", "tls_cert_file", ( + "/etc/letsencrypt/live/{}/fullchain.pem".format(self.hostname))) + self.config.set("general", "tls_key_file", ( + "/etc/letsencrypt/live/{}/privkey.pem".format(self.hostname))) + + def generate_cert(self): """Create a certificate.""" utils.printcolor( "Generating new certificate using letsencrypt", utils.YELLOW) - hostname = self.config.get("general", "hostname") utils.exec_cmd( "wget https://dl.eff.org/certbot-auto; chmod a+x certbot-auto", cwd="/opt") utils.exec_cmd( "/opt/certbot-auto certonly -n --standalone -d {} " "-m {} --agree-tos".format( - hostname, self.config.get("letsencrypt", "email"))) - self.config.set("general", "tls_cert_file", ( - "/etc/letsencrypt/live/{}/fullchain.pem".format(hostname))) - self.config.set("general", "tls_key_file", ( - "/etc/letsencrypt/live/{}/privkey.pem".format(hostname))) + self.hostname, self.config.get("letsencrypt", "email"))) with open("/etc/cron.d/letsencrypt", "w") as fp: fp.write("0 */12 * * * root /opt/certbot-auto renew " "--quiet --no-self-upgrade --force-renewal\n") - cfg_file = "/etc/letsencrypt/renewal/{}.conf".format(hostname) + cfg_file = "/etc/letsencrypt/renewal/{}.conf".format(self.hostname) pattern = "s/authenticator = standalone/authenticator = nginx/" utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, cfg_file)) diff --git a/modoboa_installer/utils.py b/modoboa_installer/utils.py index 5cd2897..08732df 100644 --- a/modoboa_installer/utils.py +++ b/modoboa_installer/utils.py @@ -145,10 +145,15 @@ def copy_from_template(template, dest, context): fp.write(ConfigFileTemplate(buf).substitute(context)) -def check_config_file(dest, interactive=False): +def check_config_file(dest, interactive=False, upgrade=False): """Create a new installer config file if needed.""" if os.path.exists(dest): return + if upgrade: + printcolor( + "You cannot upgrade an existing installation without a " + "configuration file.", RED) + sys.exit(1) printcolor( "Configuration file {} not found, creating new one." .format(dest), YELLOW) diff --git a/run.py b/run.py index debc4dd..e2bde9c 100755 --- a/run.py +++ b/run.py @@ -17,6 +17,34 @@ from modoboa_installer import system from modoboa_installer import utils +def installation_disclaimer(args, config): + """Display installation disclaimer.""" + hostname = config.get("general", "hostname") + utils.printcolor( + "Warning:\n" + "Before you start the installation, please make sure the following " + "DNS records exist for domain '{}':\n" + " {} IN A \n" + " IN MX {}.\n".format( + args.domain, + hostname.replace(".{}".format(args.domain), ""), + hostname + ), + utils.CYAN + ) + utils.printcolor( + "Your mail server will be installed with the following components:", + utils.BLUE) + + +def upgrade_disclaimer(config): + """Display upgrade disclaimer.""" + utils.printcolor( + "Your mail server is about to be upgraded and the following components" + " will be impacted:", utils.BLUE + ) + + def main(input_args): """Install process.""" parser = argparse.ArgumentParser() @@ -38,6 +66,9 @@ def main(input_args): parser.add_argument( "--interactive", action="store_true", default=False, help="Generate configuration file with user interaction") + parser.add_argument( + "--upgrade", action="store_true", default=False, + help="Run the installer in upgrade mode") parser.add_argument("domain", type=str, help="The main domain of your future mail server") args = parser.parse_args(input_args) @@ -45,7 +76,7 @@ def main(input_args): if args.debug: utils.ENV["debug"] = True utils.printcolor("Welcome to Modoboa installer!\n", utils.GREEN) - utils.check_config_file(args.configfile, args.interactive) + utils.check_config_file(args.configfile, args.interactive, args.upgrade) if args.stop_after_configfile_check: return config = configparser.SafeConfigParser() @@ -56,22 +87,12 @@ def main(input_args): config.set("general", "domain", args.domain) config.set("dovecot", "domain", args.domain) config.set("modoboa", "version", args.version) - hostname = config.get("general", "hostname") - utils.printcolor( - "Warning:\n" - "Before you start the installation, please make sure the following " - "DNS records exist for domain '{}':\n" - " {} IN A \n" - " IN MX {}.\n".format( - args.domain, - hostname.replace(".{}".format(args.domain), ""), - hostname - ), - utils.CYAN - ) - utils.printcolor( - "Your mail server will be installed with the following components:", - utils.BLUE) + # Display disclaimer + if not args.upgrade: + installation_disclaimer(args, config) + else: + upgrade_disclaimer(config) + # Show concerned components components = [] for section in config.sections(): if section in ["general", "database", "mysql", "postgres", @@ -93,17 +114,17 @@ def main(input_args): utils.printcolor("Starting...", utils.GREEN) package.backend.install_many(["sudo", "wget"]) ssl_backend = ssl.get_backend(config) - if ssl_backend: - ssl_backend.create() - scripts.install("amavis", config) - scripts.install("modoboa", config) - scripts.install("automx", config) - scripts.install("radicale", config) - scripts.install("uwsgi", config) - scripts.install("nginx", config) - scripts.install("opendkim", config) - scripts.install("postfix", config) - scripts.install("dovecot", config) + if ssl_backend and not args.upgrade: + ssl_backend.generate_cert() + scripts.install("amavis", config, args.upgrade) + scripts.install("modoboa", config, args.upgrade) + scripts.install("automx", config, args.upgrade) + scripts.install("radicale", config, args.upgrade) + scripts.install("uwsgi", config, args.upgrade) + scripts.install("nginx", config, args.upgrade) + scripts.install("opendkim", config, args.upgrade) + scripts.install("postfix", config, args.upgrade) + scripts.install("dovecot", config, args.upgrade) system.restart_service("cron") utils.printcolor( "Congratulations! You can enjoy Modoboa at https://{} (admin:password)" diff --git a/tests.py b/tests.py index 4b30735..201a252 100644 --- a/tests.py +++ b/tests.py @@ -30,6 +30,8 @@ class ConfigFileTestCase(unittest.TestCase): def test_configfile_generation(self): """Check simple case.""" + out = StringIO() + sys.stdout = out run.main([ "--stop-after-configfile-check", "--configfile", self.cfgfile, @@ -91,6 +93,45 @@ class ConfigFileTestCase(unittest.TestCase): out.getvalue() ) + @patch("modoboa_installer.utils.user_input") + def test_upgrade_mode(self, mock_user_input): + """Test upgrade mode launch.""" + mock_user_input.side_effect = ["no"] + # 1. Generate a config file + with open(os.devnull, "w") as fp: + sys.stdout = fp + run.main([ + "--stop-after-configfile-check", + "--configfile", self.cfgfile, + "example.test"]) + # 2. Run upgrade + out = StringIO() + sys.stdout = out + run.main([ + "--configfile", self.cfgfile, + "--upgrade", + "example.test"]) + self.assertIn( + "Your mail server is about to be upgraded and the following " + "components will be impacted:", + out.getvalue() + ) + + def test_upgrade_no_config_file(self): + """Check config file existence check.""" + out = StringIO() + sys.stdout = out + with self.assertRaises(SystemExit): + run.main([ + "--configfile", self.cfgfile, + "--upgrade", + "example.test" + ]) + self.assertIn( + "You cannot upgrade an existing installation without a " + "configuration file.", out.getvalue() + ) + if __name__ == "__main__": unittest.main()