Merge pull request #261 from modoboa/feature/upgrade_mode

Installer upgrade mode.
This commit is contained in:
Antoine Nguyen
2019-03-19 10:56:31 +01:00
committed by GitHub
14 changed files with 167 additions and 68 deletions

View File

@@ -42,6 +42,8 @@ The following components are installed by the installer:
* Dovecot * Dovecot
* Amavis (with SpamAssassin and ClamAV) * Amavis (with SpamAssassin and ClamAV)
* automx (autoconfiguration service) * automx (autoconfiguration service)
* OpenDKIM
* Radicale (CalDAV and CardDAV server)
If you want to customize configuration before running the installer, If you want to customize configuration before running the installer,
run the following command:: 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 If you want more information about the installation process, add the
``--debug`` option to your command line. ``--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 <your domain>
It will automatically install latest versions of modoboa and its plugins.
Change the generated hostname Change the generated hostname
----------------------------- -----------------------------

View File

@@ -6,7 +6,7 @@ import sys
from .. import utils from .. import utils
def install(appname, config): def install(appname, config, upgrade):
"""Install an application.""" """Install an application."""
if (config.has_option(appname, "enabled") and if (config.has_option(appname, "enabled") and
not config.getboolean(appname, "enabled")): not config.getboolean(appname, "enabled")):
@@ -19,7 +19,7 @@ def install(appname, config):
print("Unknown application {}".format(appname)) print("Unknown application {}".format(appname))
sys.exit(1) sys.exit(1)
try: try:
getattr(script, appname.capitalize())(config).run() getattr(script, appname.capitalize())(config, upgrade).run()
except utils.FatalError as inst: except utils.FatalError as inst:
utils.printcolor(u"{}".format(inst), utils.RED) utils.printcolor(u"{}".format(inst), utils.RED)
sys.exit(1) sys.exit(1)

View File

@@ -85,5 +85,5 @@ class Amavis(base.Installer):
"""Additional tasks.""" """Additional tasks."""
with open("/etc/mailname", "w") as fp: with open("/etc/mailname", "w") as fp:
fp.write("{}\n".format(self.config.get("general", "hostname"))) fp.write("{}\n".format(self.config.get("general", "hostname")))
install("spamassassin", self.config) install("spamassassin", self.config, self.upgrade)
install("clamav", self.config) install("clamav", self.config, self.upgrade)

View File

@@ -24,11 +24,11 @@ class Automx(base.Installer):
} }
with_user = True with_user = True
def __init__(self, config): def __init__(self, *args, **kwargs):
"""Get configuration.""" """Get configuration."""
super(Automx, self).__init__(config) super(Automx, self).__init__(*args, **kwargs)
self.venv_path = config.get("automx", "venv_path") self.venv_path = self.config.get("automx", "venv_path")
self.instance_path = config.get("automx", "instance_path") self.instance_path = self.config.get("automx", "instance_path")
def get_template_context(self): def get_template_context(self):
"""Additional variables.""" """Additional variables."""

View File

@@ -19,9 +19,10 @@ class Installer(object):
with_db = False with_db = False
config_files = [] config_files = []
def __init__(self, config): def __init__(self, config, upgrade):
"""Get configuration.""" """Get configuration."""
self.config = config self.config = config
self.upgrade = upgrade
if self.config.has_section(self.appname): if self.config.has_section(self.appname):
self.app_config = dict(self.config.items(self.appname)) self.app_config = dict(self.config.items(self.appname))
self.dbengine = self.config.get("database", "engine") self.dbengine = self.config.get("database", "engine")
@@ -67,8 +68,8 @@ class Installer(object):
self.backend.load_sql_file( self.backend.load_sql_file(
self.dbname, self.dbuser, self.dbpasswd, schema) self.dbname, self.dbuser, self.dbpasswd, schema)
def create_user(self): def setup_user(self):
"""Create a system user.""" """Setup a system user."""
if not self.with_user: if not self.with_user:
return return
self.user = self.config.get(self.appname, "user") self.user = self.config.get(self.appname, "user")
@@ -143,7 +144,8 @@ class Installer(object):
def run(self): def run(self):
"""Run the installer.""" """Run the installer."""
self.install_packages() self.install_packages()
self.create_user() self.setup_user()
if not self.upgrade:
self.setup_database() self.setup_database()
self.install_config_files() self.install_config_files()
self.post_run() self.post_run()

View File

@@ -36,13 +36,13 @@ class Modoboa(base.Installer):
with_db = True with_db = True
with_user = True with_user = True
def __init__(self, config): def __init__(self, *args, **kwargs):
"""Get configuration.""" """Get configuration."""
super(Modoboa, self).__init__(config) super(Modoboa, self).__init__(*args, **kwargs)
self.venv_path = config.get("modoboa", "venv_path") self.venv_path = self.config.get("modoboa", "venv_path")
self.instance_path = config.get("modoboa", "instance_path") self.instance_path = self.config.get("modoboa", "instance_path")
self.extensions = config.get("modoboa", "extensions").split() self.extensions = self.config.get("modoboa", "extensions").split()
self.devmode = config.getboolean("modoboa", "devmode") self.devmode = self.config.getboolean("modoboa", "devmode")
# Sanity check for amavis # Sanity check for amavis
self.amavis_enabled = False self.amavis_enabled = False
if "modoboa-amavis" in self.extensions: if "modoboa-amavis" in self.extensions:
@@ -87,7 +87,8 @@ class Modoboa(base.Installer):
packages.append(extension) packages.append(extension)
# Temp fix for https://github.com/modoboa/modoboa-installer/issues/197 # Temp fix for https://github.com/modoboa/modoboa-installer/issues/197
python.install_package( 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": if self.dbengine == "postgres":
packages.append("psycopg2-binary") packages.append("psycopg2-binary")
else: else:
@@ -99,18 +100,23 @@ class Modoboa(base.Installer):
# Temp. fix # Temp. fix
packages += [ packages += [
"https://github.com/modoboa/caldav/tarball/master#egg=caldav"] "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: if self.devmode:
# FIXME: use dev-requirements instead # FIXME: use dev-requirements instead
python.install_packages( python.install_packages(
["django-bower", "django-debug-toolbar"], self.venv_path, ["django-bower", "django-debug-toolbar"], self.venv_path,
sudo_user=self.user) upgrade=self.upgrade, sudo_user=self.user)
def _deploy_instance(self): def _deploy_instance(self):
"""Deploy Modoboa.""" """Deploy Modoboa."""
target = os.path.join(self.home_dir, "instance") target = os.path.join(self.home_dir, "instance")
if os.path.exists(target): 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( utils.printcolor(
"Target directory for Modoboa deployment ({}) already " "Target directory for Modoboa deployment ({}) already "
"exists. If you choose to continue, it will be removed." "exists. If you choose to continue, it will be removed."
@@ -239,4 +245,5 @@ class Modoboa(base.Installer):
"""Additional tasks.""" """Additional tasks."""
self._setup_venv() self._setup_venv()
self._deploy_instance() self._deploy_instance()
if not self.upgrade:
self.apply_settings() self.apply_settings()

View File

@@ -25,7 +25,8 @@ class Nginx(base.Installer):
context.update({ context.update({
"app_instance_path": ( "app_instance_path": (
self.config.get(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 return context

View File

@@ -97,4 +97,4 @@ class Postfix(base.Installer):
utils.exec_cmd("postalias {}".format(aliases_file)) utils.exec_cmd("postalias {}".format(aliases_file))
# Postwhite # Postwhite
install("postwhite", self.config) install("postwhite", self.config, self.upgrade)

View File

@@ -22,10 +22,10 @@ class Radicale(base.Installer):
} }
with_user = True with_user = True
def __init__(self, config): def __init__(self, *args, **kwargs):
"""Get configuration.""" """Get configuration."""
super(Radicale, self).__init__(config) super(Radicale, self).__init__(*args, **kwargs)
self.venv_path = config.get("radicale", "venv_path") self.venv_path = self.config.get("radicale", "venv_path")
def _setup_venv(self): def _setup_venv(self):
"""Prepare a dedicated virtualenv.""" """Prepare a dedicated virtualenv."""

View File

@@ -61,7 +61,7 @@ class Spamassassin(base.Installer):
"pyzor --homedir {} discover".format(pw[5]), "pyzor --homedir {} discover".format(pw[5]),
sudo_user=amavis_user, login=False sudo_user=amavis_user, login=False
) )
install("razor", self.config) install("razor", self.config, self.upgrade)
if utils.dist_name() in ["debian", "ubuntu"]: if utils.dist_name() in ["debian", "ubuntu"]:
utils.exec_cmd( utils.exec_cmd(
"perl -pi -e 's/^CRON=0/CRON=1/' /etc/cron.daily/spamassassin") "perl -pi -e 's/^CRON=0/CRON=1/' /etc/cron.daily/spamassassin")

View File

@@ -43,7 +43,7 @@ class SelfSignedCertificate(CertificateBackend):
return return
raise RuntimeError("Cannot find a directory to store certificate") raise RuntimeError("Cannot find a directory to store certificate")
def create(self): def generate_cert(self):
"""Create a certificate.""" """Create a certificate."""
if not self.overwrite_existing_certificate(): if not self.overwrite_existing_certificate():
return return
@@ -61,26 +61,30 @@ class SelfSignedCertificate(CertificateBackend):
class LetsEncryptCertificate(CertificateBackend): class LetsEncryptCertificate(CertificateBackend):
"""Create a certificate using letsencrypt.""" """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.""" """Create a certificate."""
utils.printcolor( utils.printcolor(
"Generating new certificate using letsencrypt", utils.YELLOW) "Generating new certificate using letsencrypt", utils.YELLOW)
hostname = self.config.get("general", "hostname")
utils.exec_cmd( utils.exec_cmd(
"wget https://dl.eff.org/certbot-auto; chmod a+x certbot-auto", "wget https://dl.eff.org/certbot-auto; chmod a+x certbot-auto",
cwd="/opt") cwd="/opt")
utils.exec_cmd( utils.exec_cmd(
"/opt/certbot-auto certonly -n --standalone -d {} " "/opt/certbot-auto certonly -n --standalone -d {} "
"-m {} --agree-tos".format( "-m {} --agree-tos".format(
hostname, self.config.get("letsencrypt", "email"))) self.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)))
with open("/etc/cron.d/letsencrypt", "w") as fp: with open("/etc/cron.d/letsencrypt", "w") as fp:
fp.write("0 */12 * * * root /opt/certbot-auto renew " fp.write("0 */12 * * * root /opt/certbot-auto renew "
"--quiet --no-self-upgrade --force-renewal\n") "--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/" pattern = "s/authenticator = standalone/authenticator = nginx/"
utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, cfg_file)) utils.exec_cmd("perl -pi -e '{}' {}".format(pattern, cfg_file))

View File

@@ -145,10 +145,15 @@ def copy_from_template(template, dest, context):
fp.write(ConfigFileTemplate(buf).substitute(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.""" """Create a new installer config file if needed."""
if os.path.exists(dest): if os.path.exists(dest):
return return
if upgrade:
printcolor(
"You cannot upgrade an existing installation without a "
"configuration file.", RED)
sys.exit(1)
printcolor( printcolor(
"Configuration file {} not found, creating new one." "Configuration file {} not found, creating new one."
.format(dest), YELLOW) .format(dest), YELLOW)

77
run.py
View File

@@ -17,6 +17,34 @@ from modoboa_installer import system
from modoboa_installer import utils 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 <IP ADDRESS OF YOUR SERVER>\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): def main(input_args):
"""Install process.""" """Install process."""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -38,6 +66,9 @@ def main(input_args):
parser.add_argument( parser.add_argument(
"--interactive", action="store_true", default=False, "--interactive", action="store_true", default=False,
help="Generate configuration file with user interaction") 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, parser.add_argument("domain", type=str,
help="The main domain of your future mail server") help="The main domain of your future mail server")
args = parser.parse_args(input_args) args = parser.parse_args(input_args)
@@ -45,7 +76,7 @@ def main(input_args):
if args.debug: if args.debug:
utils.ENV["debug"] = True utils.ENV["debug"] = True
utils.printcolor("Welcome to Modoboa installer!\n", utils.GREEN) 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: if args.stop_after_configfile_check:
return return
config = configparser.SafeConfigParser() config = configparser.SafeConfigParser()
@@ -56,22 +87,12 @@ def main(input_args):
config.set("general", "domain", args.domain) config.set("general", "domain", args.domain)
config.set("dovecot", "domain", args.domain) config.set("dovecot", "domain", args.domain)
config.set("modoboa", "version", args.version) config.set("modoboa", "version", args.version)
hostname = config.get("general", "hostname") # Display disclaimer
utils.printcolor( if not args.upgrade:
"Warning:\n" installation_disclaimer(args, config)
"Before you start the installation, please make sure the following " else:
"DNS records exist for domain '{}':\n" upgrade_disclaimer(config)
" {} IN A <IP ADDRESS OF YOUR SERVER>\n" # Show concerned components
" 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)
components = [] components = []
for section in config.sections(): for section in config.sections():
if section in ["general", "database", "mysql", "postgres", if section in ["general", "database", "mysql", "postgres",
@@ -93,17 +114,17 @@ def main(input_args):
utils.printcolor("Starting...", utils.GREEN) utils.printcolor("Starting...", utils.GREEN)
package.backend.install_many(["sudo", "wget"]) package.backend.install_many(["sudo", "wget"])
ssl_backend = ssl.get_backend(config) ssl_backend = ssl.get_backend(config)
if ssl_backend: if ssl_backend and not args.upgrade:
ssl_backend.create() ssl_backend.generate_cert()
scripts.install("amavis", config) scripts.install("amavis", config, args.upgrade)
scripts.install("modoboa", config) scripts.install("modoboa", config, args.upgrade)
scripts.install("automx", config) scripts.install("automx", config, args.upgrade)
scripts.install("radicale", config) scripts.install("radicale", config, args.upgrade)
scripts.install("uwsgi", config) scripts.install("uwsgi", config, args.upgrade)
scripts.install("nginx", config) scripts.install("nginx", config, args.upgrade)
scripts.install("opendkim", config) scripts.install("opendkim", config, args.upgrade)
scripts.install("postfix", config) scripts.install("postfix", config, args.upgrade)
scripts.install("dovecot", config) scripts.install("dovecot", config, args.upgrade)
system.restart_service("cron") system.restart_service("cron")
utils.printcolor( utils.printcolor(
"Congratulations! You can enjoy Modoboa at https://{} (admin:password)" "Congratulations! You can enjoy Modoboa at https://{} (admin:password)"

View File

@@ -30,6 +30,8 @@ class ConfigFileTestCase(unittest.TestCase):
def test_configfile_generation(self): def test_configfile_generation(self):
"""Check simple case.""" """Check simple case."""
out = StringIO()
sys.stdout = out
run.main([ run.main([
"--stop-after-configfile-check", "--stop-after-configfile-check",
"--configfile", self.cfgfile, "--configfile", self.cfgfile,
@@ -91,6 +93,45 @@ class ConfigFileTestCase(unittest.TestCase):
out.getvalue() 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__": if __name__ == "__main__":
unittest.main() unittest.main()