Merge pull request #456 from modoboa/feature/improved_backup_restore

WIP: Improved backup/restore system.
This commit is contained in:
Antoine Nguyen
2022-11-09 10:51:30 +01:00
committed by GitHub
15 changed files with 416 additions and 191 deletions

60
.github/workflows/installer.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Modoboa installer
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9]
fail-fast: false
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -r test-requirements.txt
- name: Run tests
if: ${{ matrix.python-version != '3.9' }}
run: |
python tests.py
- name: Run tests and coverage
if: ${{ matrix.python-version == '3.9' }}
run: |
coverage run tests.py
- name: Upload coverage result
if: ${{ matrix.python-version == '3.9' }}
uses: actions/upload-artifact@v2
with:
name: coverage-results
path: .coverage
coverage:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install dependencies
run: |
pip install codecov
- name: Download coverage results
uses: actions/download-artifact@v2
with:
name: coverage-results
- name: Report coverage
run: |
coverage report
codecov

View File

@@ -1,15 +0,0 @@
sudo: false
language: python
cache: pip
python:
- "2.7"
- "3.4"
before_install:
- pip install -r test-requirements.txt
script:
- coverage run tests.py
after_success:
- codecov

View File

@@ -1,7 +1,7 @@
modoboa-installer modoboa-installer
================= =================
|travis| |codecov| |workflow| |codecov|
An installer which deploy a complete mail server based on Modoboa. An installer which deploy a complete mail server based on Modoboa.
@@ -100,7 +100,7 @@ An experimental backup mode is available.
.. warning:: .. warning::
You must keep the original configuration file, i.e. the one used for You must keep the original configuration file, i.e. the one used for
the installation. Otherwise, you will need to recreate it manually with the right information ! the installation. Otherwise, you will need to recreate it manually with the right information!
You can start the process as follows:: You can start the process as follows::
@@ -108,7 +108,7 @@ You can start the process as follows::
Then follow the step on the console. Then follow the step on the console.
There are also a non-interactive mode: There is also a non-interactive mode:
1. Silent mode 1. Silent mode
@@ -116,21 +116,17 @@ Command::
$ sudo ./run.py --silent-backup <your domain> $ sudo ./run.py --silent-backup <your domain>
This mode is the silent batch mode, when executed, it will create /modoboa_backup/ and each time you execute it, it will create a new backup directory with current date and time. This mode will run silently. When executed, it will create
/modoboa_backup/ and each time you execute it, it will create a new
backup directory with current date and time.
You can supply a custom path. You can supply a custom path if needed::
Command::
$ sudo ./run.py --silent-backup --backup-path /path/of/backup/directory <your domain> $ sudo ./run.py --silent-backup --backup-path /path/of/backup/directory <your domain>
This mode is the same as silent batch mode, but you provide the path to the backup directory you want. If you want to disable emails backup, disable dovecot in the
configuration file (set enabled to False).
If you want to disable mail backup::
$ sudo ./run.py {--backup|--silent-backup} --no-mail-backup <your domain>
This can be useful for larger instance. This can be useful for larger instance.
Restore mode Restore mode
@@ -142,7 +138,7 @@ You can start the process as follows::
$ sudo ./run.py --restore /path/to/backup/directory/ <your domain> $ sudo ./run.py --restore /path/to/backup/directory/ <your domain>
Then wait for the process to finish Then wait for the process to finish.
Change the generated hostname Change the generated hostname
----------------------------- -----------------------------
@@ -187,7 +183,6 @@ modify the following settings::
Change the ``email`` setting to a valid value since it will be used Change the ``email`` setting to a valid value since it will be used
for account recovery. for account recovery.
.. |travis| image:: https://travis-ci.org/modoboa/modoboa-installer.png?branch=master .. |workflow| image:: https://github.com/modoboa/modoboa-installer/workflows/Modoboa%20installer/badge.svg
:target: https://travis-ci.org/modoboa/modoboa-installer
.. |codecov| image:: http://codecov.io/github/modoboa/modoboa-installer/coverage.svg?branch=master .. |codecov| image:: http://codecov.io/github/modoboa/modoboa-installer/coverage.svg?branch=master
:target: http://codecov.io/github/modoboa/modoboa-installer?branch=master :target: http://codecov.io/github/modoboa/modoboa-installer?branch=master

View File

@@ -6,35 +6,44 @@ import sys
from .. import utils from .. import utils
def install(appname, config, upgrade, restore): def load_app_script(appname):
"""Install an application.""" """Load module corresponding to the given appname."""
if (config.has_option(appname, "enabled") and
not config.getboolean(appname, "enabled")):
return
utils.printcolor("Installing {}".format(appname), utils.MAGENTA)
try: try:
script = importlib.import_module( script = importlib.import_module(
"modoboa_installer.scripts.{}".format(appname)) "modoboa_installer.scripts.{}".format(appname))
except ImportError: except ImportError:
print("Unknown application {}".format(appname)) print("Unknown application {}".format(appname))
sys.exit(1) sys.exit(1)
return script
def install(appname: str, config, upgrade: bool, archive_path: str):
"""Install an application."""
if (config.has_option(appname, "enabled") and
not config.getboolean(appname, "enabled")):
return
utils.printcolor("Installing {}".format(appname), utils.MAGENTA)
script = load_app_script(appname)
try: try:
getattr(script, appname.capitalize())(config, upgrade, restore).run() getattr(script, appname.capitalize())(config, upgrade, archive_path).run()
except utils.FatalError as inst: except utils.FatalError as inst:
utils.printcolor(u"{}".format(inst), utils.RED) utils.error("{}".format(inst))
sys.exit(1) sys.exit(1)
def backup(config, silent_backup, backup_path, nomail): def backup(appname, config, path):
"""Backup instance""" """Backup an application."""
script = importlib.import_module( if (config.has_option(appname, "enabled") and
"modoboa_installer.scripts.backup") not config.getboolean(appname, "enabled")):
return
utils.printcolor("Backing up {}".format(appname), utils.MAGENTA)
script = load_app_script(appname)
try: try:
getattr(script, "Backup")( getattr(script, appname.capitalize())(config, False, False).backup(path)
config, silent_backup, backup_path, nomail).run()
except utils.FatalError as inst: except utils.FatalError as inst:
utils.printcolor(u"{}".format(inst), utils.RED) utils.error("{}".format(inst))
sys.exit(1) sys.exit(1)

View File

@@ -6,7 +6,7 @@ from .. import package
from .. import utils from .. import utils
from . import base from . import base
from . import install from . import backup, install
class Amavis(base.Installer): class Amavis(base.Installer):
@@ -42,14 +42,6 @@ class Amavis(base.Installer):
def get_config_files(self): def get_config_files(self):
"""Return appropriate config files.""" """Return appropriate config files."""
if package.backend.FORMAT == "deb": if package.backend.FORMAT == "deb":
if self.restore is not None:
amavis_custom_configuration = os.path.join(
self.restore, "custom/99-custom")
if os.path.isfile(amavis_custom_configuration):
utils.copy_file(amavis_custom_configuration, os.path.join(
self.config_dir, "conf.d"))
utils.printcolor(
"Custom amavis configuration restored.", utils.GREEN)
return [ return [
"conf.d/05-node_id", "conf.d/15-content_filter_mode", "conf.d/05-node_id", "conf.d/15-content_filter_mode",
"conf.d/50-user"] "conf.d/50-user"]
@@ -77,11 +69,6 @@ class Amavis(base.Installer):
def get_sql_schema_path(self): def get_sql_schema_path(self):
"""Return schema path.""" """Return schema path."""
if self.restore:
db_dump_path = self._restore_database_dump("amavis")
if db_dump_path is not None:
return db_dump_path
version = package.backend.get_installed_version("amavisd-new") version = package.backend.get_installed_version("amavisd-new")
if version is None: if version is None:
# Fallback to amavis... # Fallback to amavis...
@@ -105,5 +92,25 @@ class Amavis(base.Installer):
def post_run(self): def post_run(self):
"""Additional tasks.""" """Additional tasks."""
install("spamassassin", self.config, self.upgrade, self.restore) install("spamassassin", self.config, self.upgrade, self.archive_path)
install("clamav", self.config, self.upgrade, self.restore) install("clamav", self.config, self.upgrade, self.archive_path)
def custom_backup(self, path):
"""Backup custom configuration if any."""
if package.backend.FORMAT == "deb":
amavis_custom = f"{self.config_dir}/conf.d/99-custom"
if os.path.isfile(amavis_custom):
utils.copy_file(amavis_custom, path)
utils.success("Amavis custom configuration saved!")
backup("spamassassin", self.config, os.path.dirname(path))
def restore(self):
"""Restore custom config files."""
if package.backend.FORMAT != "deb":
return
amavis_custom_configuration = os.path.join(
self.archive_path, "custom/99-custom")
if os.path.isfile(amavis_custom_configuration):
utils.copy_file(amavis_custom_configuration, os.path.join(
self.config_dir, "conf.d"))
utils.success("Custom amavis configuration restored.")

View File

@@ -20,11 +20,11 @@ class Installer(object):
with_db = False with_db = False
config_files = [] config_files = []
def __init__(self, config, upgrade, restore): def __init__(self, config, upgrade: bool, archive_path: str):
"""Get configuration.""" """Get configuration."""
self.config = config self.config = config
self.upgrade = upgrade self.upgrade = upgrade
self.restore = restore self.archive_path = archive_path
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")
@@ -54,6 +54,19 @@ class Installer(object):
"""Return a schema to install.""" """Return a schema to install."""
return None return None
def get_sql_schema_from_backup(self):
"""Retrieve a dump path from a previous backup."""
utils.printcolor(
f"Trying to restore {self.appname} database from backup.",
utils.MAGENTA
)
database_backup_path = os.path.join(
self.archive_path, f"databases/{self.appname}.sql")
if os.path.isfile(database_backup_path):
utils.success(f"SQL dump found in backup for {self.appname}!")
return database_backup_path
return None
def get_file_path(self, fname): def get_file_path(self, fname):
"""Return the absolute path of this file.""" """Return the absolute path of this file."""
return os.path.abspath( return os.path.abspath(
@@ -67,7 +80,11 @@ class Installer(object):
return return
self.backend.create_user(self.dbuser, self.dbpasswd) self.backend.create_user(self.dbuser, self.dbpasswd)
self.backend.create_database(self.dbname, self.dbuser) self.backend.create_database(self.dbname, self.dbuser)
schema = self.get_sql_schema_path() schema = None
if self.archive_path:
schema = self.get_sql_schema_from_backup()
if not schema:
schema = self.get_sql_schema_path()
if schema: if schema:
self.backend.load_sql_file( self.backend.load_sql_file(
self.dbname, self.dbuser, self.dbpasswd, schema) self.dbname, self.dbuser, self.dbpasswd, schema)
@@ -138,6 +155,20 @@ class Installer(object):
dst = os.path.join(self.config_dir, dst) dst = os.path.join(self.config_dir, dst)
utils.copy_from_template(src, dst, context) utils.copy_from_template(src, dst, context)
def backup(self, path):
if self.with_db:
self._dump_database(path)
custom_backup_path = os.path.join(path, "custom")
self.custom_backup(custom_backup_path)
def custom_backup(self, path):
"""Override this method in subscripts to add custom backup content."""
pass
def restore(self):
"""Restore from a previous backup."""
pass
def get_daemon_name(self): def get_daemon_name(self):
"""Return daemon name if defined.""" """Return daemon name if defined."""
return self.daemon_name if self.daemon_name else self.appname return self.daemon_name if self.daemon_name else self.appname
@@ -158,21 +189,16 @@ class Installer(object):
self.setup_database() self.setup_database()
self.install_config_files() self.install_config_files()
self.post_run() self.post_run()
if self.archive_path:
self.restore()
self.restart_daemon() self.restart_daemon()
def _restore_database_dump(self, app_name): def _dump_database(self, backup_path: str):
"""Restore database dump from a dump.""" """Create a new database dump for this app."""
target_dir = os.path.join(backup_path, "databases")
utils.printcolor( target_file = os.path.join(target_dir, f"{self.appname}.sql")
f"Trying to restore {app_name} database from backup.", utils.MAGENTA) self.backend.dump_database(
database_backup_path = os.path.join( self.dbname, self.dbuser, self.dbpasswd, target_file)
self.restore, f"databases/{app_name}.sql")
if os.path.isfile(database_backup_path):
utils.printcolor(
f"{app_name.capitalize()} database backup found ! Restoring...", utils.GREEN)
return database_backup_path
utils.printcolor(
f"{app_name.capitalize()} database backup not found, creating empty database.", utils.RED)
def pre_run(self): def pre_run(self):
"""Tasks to execute before the installer starts.""" """Tasks to execute before the installer starts."""

View File

@@ -96,28 +96,6 @@ class Dovecot(base.Installer):
def post_run(self): def post_run(self):
"""Additional tasks.""" """Additional tasks."""
if self.restore is not None:
mail_dir = os.path.join(self.restore, "mails/")
if len(os.listdir(mail_dir)) > 0:
utils.printcolor(
"Copying mail backup over dovecot directory.", utils.GREEN)
if os.path.exists(self.home_dir):
shutil.rmtree(self.home_dir)
shutil.copytree(mail_dir, self.home_dir)
# Resetting permission for vmail
for dirpath, dirnames, filenames in os.walk(self.home_dir):
shutil.chown(dirpath, self.user, self.user)
for filename in filenames:
shutil.chown(os.path.join(dirpath, filename),
self.user, self.user)
else:
utils.printcolor(
"It seems that mails were not backed up, skipping mail restoration.",
utils.MAGENTA
)
if self.dbengine == "postgres": if self.dbengine == "postgres":
dbname = self.config.get("modoboa", "dbname") dbname = self.config.get("modoboa", "dbname")
dbuser = self.config.get("modoboa", "dbuser") dbuser = self.config.get("modoboa", "dbuser")
@@ -156,3 +134,39 @@ class Dovecot(base.Installer):
"service {} {} > /dev/null 2>&1".format(self.appname, action), "service {} {} > /dev/null 2>&1".format(self.appname, action),
capture_output=False) capture_output=False)
system.enable_service(self.get_daemon_name()) system.enable_service(self.get_daemon_name())
def backup(self, path):
"""Backup emails."""
home_dir = self.config.get("dovecot", "home_dir")
utils.printcolor("Backing up mails", utils.MAGENTA)
if not os.path.exists(home_dir) or os.path.isfile(home_dir):
utils.error("Error backing up emails, provided path "
f" ({home_dir}) seems not right...")
return
dst = os.path.join(path, "mails/")
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(home_dir, dst)
utils.success("Mail backup complete!")
def restore(self):
"""Restore emails."""
home_dir = self.config.get("dovecot", "home_dir")
mail_dir = os.path.join(self.archive_path, "mails/")
if len(os.listdir(mail_dir)) > 0:
utils.success("Copying mail backup over dovecot directory.")
if os.path.exists(home_dir):
shutil.rmtree(home_dir)
shutil.copytree(mail_dir, home_dir)
# Resetting permission for vmail
for dirpath, dirnames, filenames in os.walk(home_dir):
shutil.chown(dirpath, self.user, self.user)
for filename in filenames:
shutil.chown(os.path.join(dirpath, filename),
self.user, self.user)
else:
utils.printcolor(
"It seems that emails were not backed up, skipping restoration.",
utils.MAGENTA
)

View File

@@ -192,14 +192,6 @@ class Modoboa(base.Installer):
self.backend.grant_access( self.backend.grant_access(
self.config.get("amavis", "dbname"), self.dbuser) self.config.get("amavis", "dbname"), self.dbuser)
def get_sql_schema_path(self):
if self.restore is not None:
db_dump_path = self._restore_database_dump("modoboa")
if db_dump_path is not None:
return db_dump_path
return super(Modoboa, self).get_sql_schema_path()
def get_packages(self): def get_packages(self):
"""Include extra packages if needed.""" """Include extra packages if needed."""
packages = super(Modoboa, self).get_packages() packages = super(Modoboa, self).get_packages()

View File

@@ -2,6 +2,7 @@
import os import os
import pwd import pwd
import shutil
import stat import stat
from .. import database from .. import database
@@ -46,19 +47,7 @@ class Opendkim(base.Installer):
stat.S_IROTH | stat.S_IXOTH, stat.S_IROTH | stat.S_IXOTH,
target[1], target[2] target[1], target[2]
) )
# Restore dkim keys from backup if restoring super().install_config_files()
if self.restore is not None:
dkim_keys_backup = os.path.join(
self.restore, "custom/dkim")
if os.path.isdir(dkim_keys_backup):
for file in os.listdir(dkim_keys_backup):
file_path = os.path.join(dkim_keys_backup, file)
if os.path.isfile(file_path):
utils.copy_file(file_path, self.config.get(
"opendkim", "keys_storage_dir", fallback="/var/lib/dkim"))
utils.printcolor(
"DKIM keys restored from backup", utils.GREEN)
super(Opendkim, self).install_config_files()
def get_template_context(self): def get_template_context(self):
"""Additional variables.""" """Additional variables."""
@@ -121,3 +110,24 @@ class Opendkim(base.Installer):
"s/^After=(.*)$/After=$1 {}/".format(dbservice)) "s/^After=(.*)$/After=$1 {}/".format(dbservice))
utils.exec_cmd( utils.exec_cmd(
"perl -pi -e '{}' /lib/systemd/system/opendkim.service".format(pattern)) "perl -pi -e '{}' /lib/systemd/system/opendkim.service".format(pattern))
def restore(self):
"""Restore keys."""
dkim_keys_backup = os.path.join(
self.archive_path, "custom/dkim")
if os.path.isdir(dkim_keys_backup):
for file in os.listdir(dkim_keys_backup):
file_path = os.path.join(dkim_keys_backup, file)
if os.path.isfile(file_path):
utils.copy_file(file_path, self.config.get(
"opendkim", "keys_storage_dir", fallback="/var/lib/dkim"))
utils.success("DKIM keys restored from backup")
def custom_backup(self, path):
"""Backup DKIM keys."""
storage_dir = self.config.get(
"opendkim", "keys_storage_dir", fallback="/var/lib/dkim")
if os.path.isdir(storage_dir):
shutil.copytree(storage_dir, os.path.join(path, "dkim"))
utils.printcolor(
"DKIM keys saved!", utils.GREEN)

View File

@@ -10,7 +10,7 @@ from .. import package
from .. import utils from .. import utils
from . import base from . import base
from . import install from . import backup, install
class Postfix(base.Installer): class Postfix(base.Installer):
@@ -97,4 +97,8 @@ class Postfix(base.Installer):
utils.exec_cmd("postalias {}".format(aliases_file)) utils.exec_cmd("postalias {}".format(aliases_file))
# Postwhite # Postwhite
install("postwhite", self.config, self.upgrade, self.restore) install("postwhite", self.config, self.upgrade, self.archive_path)
def backup(self, path):
"""Launch postwhite backup."""
backup("postwhite", self.config, path)

View File

@@ -45,17 +45,26 @@ class Postwhite(base.Installer):
"""Additionnal tasks.""" """Additionnal tasks."""
install_dir = "/usr/local/bin" install_dir = "/usr/local/bin"
self.install_from_archive(SPF_TOOLS_REPOSITORY, install_dir) self.install_from_archive(SPF_TOOLS_REPOSITORY, install_dir)
postw_dir = self.install_from_archive( self.postw_dir = self.install_from_archive(
POSTWHITE_REPOSITORY, install_dir) POSTWHITE_REPOSITORY, install_dir)
# Attempt to restore config file from backup postw_bin = os.path.join(self.postw_dir, "postwhite")
if self.restore is not None:
postwhite_backup_configuration = os.path.join(
self.restore, "custom/postwhite.conf")
if os.path.isfile(postwhite_backup_configuration):
utils.copy_file(postwhite_backup_configuration, self.config_dir)
utils.printcolor(
"postwhite.conf restored from backup", utils.GREEN)
else:
utils.copy_file(os.path.join(postw_dir, "postwhite.conf"), self.config_dir)
postw_bin = os.path.join(postw_dir, "postwhite")
utils.exec_cmd("{} /etc/postwhite.conf".format(postw_bin)) utils.exec_cmd("{} /etc/postwhite.conf".format(postw_bin))
def custom_backup(self, path):
"""Backup custom configuration if any."""
postswhite_custom = "/etc/postwhite.conf"
if os.path.isfile(postswhite_custom):
utils.copy_file(postswhite_custom, path)
utils.printcolor(
"Postwhite configuration saved!", utils.GREEN)
def restore(self):
"""Restore config files."""
postwhite_backup_configuration = os.path.join(
self.archive_path, "custom/postwhite.conf")
if os.path.isfile(postwhite_backup_configuration):
utils.copy_file(postwhite_backup_configuration, self.config_dir)
utils.success("postwhite.conf restored from backup")
else:
utils.copy_file(
os.path.join(self.postw_dir, "postwhite.conf"), self.config_dir)

View File

@@ -26,7 +26,7 @@ class Radicale(base.Installer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Get configuration.""" """Get configuration."""
super(Radicale, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.venv_path = self.config.get("radicale", "venv_path") self.venv_path = self.config.get("radicale", "venv_path")
def _setup_venv(self): def _setup_venv(self):
@@ -71,18 +71,18 @@ class Radicale(base.Installer):
stat.S_IROTH | stat.S_IXOTH, stat.S_IROTH | stat.S_IXOTH,
0, 0 0, 0
) )
# Attempt to restore radicale collections from backup super().install_config_files()
if self.restore is not None:
radicale_backup = os.path.join( def restore(self):
self.restore, "custom/radicale") """Restore collections."""
if os.path.isdir(radicale_backup): radicale_backup = os.path.join(
restore_target = os.path.join(self.home_dir, "collections") self.archive_path, "custom/radicale")
if os.path.isdir(restore_target): if os.path.isdir(radicale_backup):
shutil.rmtree(restore_target) restore_target = os.path.join(self.home_dir, "collections")
shutil.copytree(radicale_backup, restore_target) if os.path.isdir(restore_target):
utils.printcolor( shutil.rmtree(restore_target)
"Radicale collections restored from backup", utils.GREEN) shutil.copytree(radicale_backup, restore_target)
super(Radicale, self).install_config_files() utils.success("Radicale collections restored from backup")
def post_run(self): def post_run(self):
"""Additional tasks.""" """Additional tasks."""
@@ -93,3 +93,12 @@ class Radicale(base.Installer):
system.enable_service(daemon_name) system.enable_service(daemon_name)
utils.exec_cmd("service {} stop".format(daemon_name)) utils.exec_cmd("service {} stop".format(daemon_name))
utils.exec_cmd("service {} start".format(daemon_name)) utils.exec_cmd("service {} start".format(daemon_name))
def custom_backup(self, path):
"""Backup collections."""
radicale_backup = os.path.join(self.config.get(
"radicale", "home_dir", fallback="/srv/radicale"), "collections")
if os.path.isdir(radicale_backup):
shutil.copytree(radicale_backup, os.path.join(
path, "radicale"))
utils.printcolor("Radicale files saved", utils.GREEN)

View File

@@ -25,10 +25,6 @@ class Spamassassin(base.Installer):
def get_sql_schema_path(self): def get_sql_schema_path(self):
"""Return SQL schema.""" """Return SQL schema."""
if self.restore is not None:
db_dump_path = self._restore_database_dump("spamassassin")
if db_dump_path is not None:
return db_dump_path
if self.dbengine == "postgres": if self.dbengine == "postgres":
fname = "bayes_pg.sql" fname = "bayes_pg.sql"
else: else:

View File

@@ -226,6 +226,16 @@ def printcolor(message, color):
print(message) print(message)
def error(message):
"""Print error message."""
printcolor(message, RED)
def success(message):
"""Print success message."""
printcolor(message, GREEN)
def convert_version_to_int(version): def convert_version_to_int(version):
"""Convert a version string to an integer.""" """Convert a version string to an integer."""
number_bits = (8, 8, 16) number_bits = (8, 8, 16)
@@ -335,6 +345,57 @@ def gen_config(dest, interactive=False):
current_username = getpass.getuser() current_username = getpass.getuser()
current_user = pwd.getpwnam(current_username) current_user = pwd.getpwnam(current_username)
os.chown(dest, current_user[2], current_user[3]) os.chown(dest, current_user[2], current_user[3])
os.chmod(dest, stat.S_IRUSR|stat.S_IWUSR) os.chmod(dest, stat.S_IRUSR | stat.S_IWUSR)
def validate_backup_path(path: str, silent_mode: bool):
"""Check if provided backup path is valid or not."""
path_exists = os.path.exists(path)
if path_exists and os.path.isfile(path):
printcolor(
"Error, you provided a file instead of a directory!", RED)
return None
if not path_exists:
if not silent_mode:
create_dir = input(
f"\"{path}\" doesn't exist, would you like to create it? [Y/n]\n"
).lower()
if silent_mode or (not silent_mode and create_dir.startswith("y")):
pw = pwd.getpwnam("root")
mkdir_safe(path, stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
else:
printcolor(
"Error, backup directory not present.", RED
)
return None
if len(os.listdir(path)) != 0:
if not silent_mode:
delete_dir = input(
"Warning: backup directory is not empty, it will be purged if you continue... [Y/n]\n").lower()
if silent_mode or (not silent_mode and delete_dir.startswith("y")):
try:
os.remove(os.path.join(path, "installer.cfg"))
except FileNotFoundError:
pass
shutil.rmtree(os.path.join(path, "custom"),
ignore_errors=False)
shutil.rmtree(os.path.join(path, "mails"), ignore_errors=False)
shutil.rmtree(os.path.join(path, "databases"),
ignore_errors=False)
else:
printcolor(
"Error: backup directory not clean.", RED
)
return None
backup_path = path
pw = pwd.getpwnam("root")
for dir in ["custom/", "databases/"]:
mkdir_safe(os.path.join(backup_path, dir),
stat.S_IRWXU | stat.S_IRWXG, pw[2], pw[3])
return backup_path

120
run.py
View File

@@ -3,6 +3,7 @@
"""An installer for Modoboa.""" """An installer for Modoboa."""
import argparse import argparse
import datetime
import os import os
try: try:
import configparser import configparser
@@ -11,6 +12,7 @@ except ImportError:
import sys import sys
from modoboa_installer import compatibility_matrix from modoboa_installer import compatibility_matrix
from modoboa_installer import constants
from modoboa_installer import package from modoboa_installer import package
from modoboa_installer import scripts from modoboa_installer import scripts
from modoboa_installer import ssl from modoboa_installer import ssl
@@ -18,6 +20,19 @@ from modoboa_installer import system
from modoboa_installer import utils from modoboa_installer import utils
PRIMARY_APPS = [
"amavis",
"modoboa",
"automx",
"radicale",
"uwsgi",
"nginx",
"opendkim",
"postfix",
"dovecot"
]
def installation_disclaimer(args, config): def installation_disclaimer(args, config):
"""Display installation disclaimer.""" """Display installation disclaimer."""
hostname = config.get("general", "hostname") hostname = config.get("general", "hostname")
@@ -49,27 +64,63 @@ def upgrade_disclaimer(config):
def backup_disclaimer(): def backup_disclaimer():
"""Display backup disclamer. """ """Display backup disclamer. """
utils.printcolor( utils.printcolor(
"Your mail server will be backed up (messages and databases) locally." "Your mail server will be backed up locally.\n"
" !! You should really transfer the backup somewhere else..." " !! You should really transfer the backup somewhere else...\n"
" Custom configuration (like to postfix) won't be saved.", utils.BLUE) " !! Custom configuration (like for postfix) won't be saved.", utils.BLUE)
def restore_disclaimer(): def restore_disclaimer():
"""Display restore disclamer. """ """Display restore disclamer. """
utils.printcolor( utils.printcolor(
"You are about to restore a previous installation of Modoboa." "You are about to restore a previous installation of Modoboa.\n"
"If a new version has been released in between, please update your database !", "If a new version has been released in between, please update your database!",
utils.BLUE) utils.BLUE)
def backup_system(config, args):
"""Launch backup procedure."""
backup_disclaimer()
backup_path = None
if args.silent_backup:
if not args.backup_path:
if config.has_option("backup", "default_path"):
path = config.get("backup", "default_path")
else:
path = constants.DEFAULT_BACKUP_DIRECTORY
date = datetime.datetime.now().strftime("%m_%d_%Y_%H_%M")
path = os.path.join(path, f"backup_{date}")
else:
path = args.backup_path
backup_path = utils.validate_backup_path(path, args.silent_backup)
if not backup_path:
utils.printcolor(f"Path provided: {path}", utils.BLUE)
return
else:
user_value = None
while not user_value or not backup_path:
utils.printcolor(
"Enter backup path (it must be an empty directory)",
utils.MAGENTA
)
utils.printcolor("CTRL+C to cancel", utils.MAGENTA)
user_value = utils.user_input("-> ")
if not user_value:
continue
backup_path = utils.validate_backup_path(user_value, args.silent_backup)
# Backup configuration file
utils.copy_file(args.configfile, backup_path)
# Backup applications
for app in PRIMARY_APPS:
scripts.backup(app, config, backup_path)
def main(input_args): def main(input_args):
"""Install process.""" """Install process."""
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
versions = ( versions = (
["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys()) ["latest"] + list(compatibility_matrix.COMPATIBILITY_MATRIX.keys())
) )
parser.add_argument("--backup", action="store_true", default=False,
help="Backing up interactively previously installed instance")
parser.add_argument("--debug", action="store_true", default=False, parser.add_argument("--debug", action="store_true", default=False,
help="Enable debug output") help="Enable debug output")
parser.add_argument("--force", action="store_true", default=False, parser.add_argument("--force", action="store_true", default=False,
@@ -94,13 +145,14 @@ def main(input_args):
parser.add_argument( parser.add_argument(
"--backup-path", type=str, metavar="path", "--backup-path", type=str, metavar="path",
help="To use with --silent-backup, you must provide a valid path") help="To use with --silent-backup, you must provide a valid path")
parser.add_argument(
"--backup", action="store_true", default=False,
help="Backing up interactively previously installed instance"
)
parser.add_argument( parser.add_argument(
"--silent-backup", action="store_true", default=False, "--silent-backup", action="store_true", default=False,
help="For script usage, do not require user interaction " help="For script usage, do not require user interaction "
"backup will be saved at ./modoboa_backup/Backup_M_Y_d_H_M if --backup-path is not provided") "backup will be saved at ./modoboa_backup/Backup_M_Y_d_H_M if --backup-path is not provided")
parser.add_argument(
"--no-mail-backup", action="store_true", default=False,
help="Disable mail backup (save space)")
parser.add_argument( parser.add_argument(
"--restore", type=str, metavar="path", "--restore", type=str, metavar="path",
help="Restore a previously backup up modoboa instance on a NEW machine. " help="Restore a previously backup up modoboa instance on a NEW machine. "
@@ -117,17 +169,20 @@ def main(input_args):
is_restoring = False is_restoring = False
if args.restore is not None: if args.restore is not None:
is_restoring = True is_restoring = True
args.configfile = os.path.join(args.restore, "installer.cfg") args.configfile = os.path.join(args.restore, args.configfile)
if not os.path.exists(args.configfile): if not os.path.exists(args.configfile):
utils.printcolor("installer.cfg from backup not found!", utils.RED) utils.error(
"Installer configuration file not found in backup!"
)
sys.exit(1) sys.exit(1)
utils.printcolor("Welcome to Modoboa installer!\n", utils.GREEN) utils.success("Welcome to Modoboa installer!\n")
is_config_file_available = utils.check_config_file( is_config_file_available = utils.check_config_file(
args.configfile, args.interactive, args.upgrade, args.backup, is_restoring) args.configfile, args.interactive, args.upgrade, args.backup, is_restoring)
if is_config_file_available and args.backup: if not is_config_file_available and (
utils.printcolor("No config file found,", utils.RED) args.upgrade or args.backup or args.silent_backup):
utils.error("No config file found.")
return return
if args.stop_after_configfile_check: if args.stop_after_configfile_check:
@@ -142,14 +197,14 @@ def main(input_args):
config.set("dovecot", "domain", args.domain) config.set("dovecot", "domain", args.domain)
config.set("modoboa", "version", args.version) config.set("modoboa", "version", args.version)
config.set("modoboa", "install_beta", str(args.beta)) config.set("modoboa", "install_beta", str(args.beta))
if args.backup or args.silent_backup:
backup_system(config, args)
return
# Display disclaimer python 3 linux distribution # Display disclaimer python 3 linux distribution
if args.upgrade: if args.upgrade:
upgrade_disclaimer(config) upgrade_disclaimer(config)
elif args.backup or args.silent_backup:
backup_disclaimer()
scripts.backup(config, args.silent_backup,
args.backup_path, args.no_mail_backup)
return
elif args.restore: elif args.restore:
restore_disclaimer() restore_disclaimer()
scripts.restore_prep(args.restore) scripts.restore_prep(args.restore)
@@ -175,33 +230,26 @@ def main(input_args):
utils.printcolor( utils.printcolor(
"The process can be long, feel free to take a coffee " "The process can be long, feel free to take a coffee "
"and come back later ;)", utils.BLUE) "and come back later ;)", utils.BLUE)
utils.printcolor("Starting...", utils.GREEN) utils.success("Starting...")
package.backend.prepare_system() package.backend.prepare_system()
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 and not args.upgrade: if ssl_backend and not args.upgrade:
ssl_backend.generate_cert() ssl_backend.generate_cert()
scripts.install("amavis", config, args.upgrade, args.restore) for appname in PRIMARY_APPS:
scripts.install("modoboa", config, args.upgrade, args.restore) scripts.install(appname, config, args.upgrade, args.restore)
scripts.install("automx", config, args.upgrade, args.restore)
scripts.install("radicale", config, args.upgrade, args.restore)
scripts.install("uwsgi", config, args.upgrade, args.restore)
scripts.install("nginx", config, args.upgrade, args.restore)
scripts.install("opendkim", config, args.upgrade, args.restore)
scripts.install("postfix", config, args.upgrade, args.restore)
scripts.install("dovecot", config, args.upgrade, args.restore)
system.restart_service("cron") system.restart_service("cron")
package.backend.restore_system() package.backend.restore_system()
if not args.restore: if not args.restore:
utils.printcolor( utils.success(
"Congratulations! You can enjoy Modoboa at https://{} (admin:password)" "Congratulations! You can enjoy Modoboa at https://{} (admin:password)"
.format(config.get("general", "hostname")), .format(config.get("general", "hostname"))
utils.GREEN) )
else: else:
utils.printcolor( utils.success(
"Restore complete! You can enjoy Modoboa at https://{} (same credentials as before)" "Restore complete! You can enjoy Modoboa at https://{} (same credentials as before)"
.format(config.get("general", "hostname")), .format(config.get("general", "hostname"))
utils.GREEN) )
if __name__ == "__main__": if __name__ == "__main__":