Merge pull request #485 from modoboa/upgrade-config-file

Added ability to update configfile
This commit is contained in:
Antoine Nguyen
2023-03-14 08:35:26 +01:00
committed by GitHub
8 changed files with 161 additions and 25 deletions

View File

@@ -44,8 +44,7 @@ class Backup:
path_exists = os.path.exists(path)
if path_exists and os.path.isfile(path):
utils.printcolor(
"Error, you provided a file instead of a directory!", utils.RED)
utils.error("Error, you provided a file instead of a directory!")
return False
if not path_exists:
@@ -58,9 +57,7 @@ class Backup:
utils.mkdir_safe(path, stat.S_IRWXU |
stat.S_IRWXG, pw[2], pw[3])
else:
utils.printcolor(
"Error, backup directory not present.", utils.RED
)
utils.error("Error, backup directory not present.")
return False
if len(os.listdir(path)) != 0:
@@ -80,9 +77,7 @@ class Backup:
shutil.rmtree(os.path.join(path, "databases"),
ignore_errors=False)
else:
utils.printcolor(
"Error: backup directory not clean.", utils.RED
)
utils.error("Error: backup directory not clean.")
return False
self.backup_path = path
@@ -131,8 +126,8 @@ class Backup:
home_path = self.config.get("dovecot", "home_dir")
if not os.path.exists(home_path) or os.path.isfile(home_path):
utils.printcolor("Error backing up Email, provided path "
f" ({home_path}) seems not right...", utils.RED)
utils.error("Error backing up Email, provided path "
f" ({home_path}) seems not right...")
else:
dst = os.path.join(self.backup_path, "mails/")

View File

@@ -131,7 +131,7 @@ class Installer(object):
return
exitcode, output = package.backend.install_many(packages)
if exitcode:
utils.printcolor("Failed to install dependencies", utils.RED)
utils.error("Failed to install dependencies")
sys.exit(1)
def get_config_files(self):

View File

@@ -13,14 +13,14 @@ class Restore:
"""
if not os.path.isdir(restore):
utils.printcolor(
"Provided path is not a directory !", utils.RED)
utils.error(
"Provided path is not a directory !")
sys.exit(1)
modoba_sql_file = os.path.join(restore, "databases/modoboa.sql")
if not os.path.isfile(modoba_sql_file):
utils.printcolor(
modoba_sql_file + " not found, please check your backup", utils.RED)
utils.error(
modoba_sql_file + " not found, please check your backup")
sys.exit(1)
# Everything seems alright here, proceeding...

View File

@@ -90,7 +90,7 @@ class LetsEncryptCertificate(CertificateBackend):
elif "centos" in name:
package.backend.install("certbot")
else:
utils.printcolor("Failed to install certbot, aborting.", utils.RED)
utils.printcolor("Failed to install certbot, aborting.")
sys.exit(1)
# Nginx plugin certbot
if (

View File

@@ -177,7 +177,7 @@ def check_config_file(dest, interactive=False, upgrade=False, backup=False, rest
"""Create a new installer config file if needed."""
is_present = True
if os.path.exists(dest):
return is_present
return is_present, update_config(dest, False)
if upgrade:
printcolor(
"You cannot upgrade an existing installation without a "
@@ -198,7 +198,7 @@ def check_config_file(dest, interactive=False, upgrade=False, backup=False, rest
"Configuration file {} not found, creating new one."
.format(dest), YELLOW)
gen_config(dest, interactive)
return is_present
return is_present, None
def has_colours(stream):
@@ -320,8 +320,8 @@ def get_entry_value(entry, interactive):
return user_value if user_value else default_value
def gen_config(dest, interactive=False):
"""Create config file from dict template"""
def load_config_template(interactive):
"""Instantiate a configParser object with the predefined template."""
tpl_dict = config_dict_template.ConfigDictTemplate
config = configparser.ConfigParser()
# only ask about options we need, else still generate default
@@ -337,6 +337,82 @@ def gen_config(dest, interactive=False):
for config_entry in section["values"]:
value = get_entry_value(config_entry, interactive_section)
config.set(section["name"], config_entry["option"], value)
return config
def update_config(path, apply_update=True):
"""Update an existing config file."""
config = configparser.ConfigParser()
with open(path) as fp:
config.read_file(fp)
new_config = load_config_template(False)
old_sections = config.sections()
new_sections = new_config.sections()
update = False
dropped_sections = list(set(old_sections) - set(new_sections))
added_sections = list(set(new_sections) - set(old_sections))
if len(dropped_sections) > 0 and apply_update:
printcolor("Following section(s) will not be ported "
"due to being deleted or renamed: " +
', '.join(dropped_sections),
RED)
if len(dropped_sections) + len(added_sections) > 0:
update = True
for section in new_sections:
if section in old_sections:
new_options = new_config.options(section)
old_options = config.options(section)
dropped_options = list(set(old_options) - set(new_options))
added_options = list(set(new_options) - set(old_options))
if len(dropped_options) > 0 and apply_update:
printcolor(f"Following option(s) from section: {section}, "
"will not be ported due to being "
"deleted or renamed: " +
', '.join(dropped_options),
RED)
if len(dropped_options) + len(added_options) > 0:
update = True
if apply_update:
for option in new_options:
if option in old_options:
value = config.get(section, option, raw=True)
if value != new_config.get(section, option, raw=True):
update = True
new_config.set(section, option, value)
if apply_update:
if update:
# Backing up old config file
date = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
dest = f"{os.path.splitext(path)[0]}_{date}.old"
shutil.copy(path, dest)
# Overwritting old config file
with open(path, "w") as configfile:
new_config.write(configfile)
# Set file owner to running u+g, and set config file permission to 600
current_username = getpass.getuser()
current_user = pwd.getpwnam(current_username)
os.chown(dest, current_user[2], current_user[3])
os.chmod(dest, stat.S_IRUSR | stat.S_IWUSR)
return dest
return None
else:
# Simply check if current config file is outdated
return update
def gen_config(dest, interactive=False):
"""Create config file from dict template"""
config = load_config_template(interactive)
with open(dest, "w") as configfile:
config.write(configfile)

29
run.py
View File

@@ -116,6 +116,15 @@ def backup_system(config, args):
scripts.backup(app, config, backup_path)
def config_file_update_complete(backup_location):
utils.printcolor("Update complete. It seems successful.",
utils.BLUE)
if backup_location is not None:
utils.printcolor("You will find your old config file "
f"here: {backup_location}",
utils.BLUE)
def main(input_args):
"""Install process."""
parser = argparse.ArgumentParser()
@@ -153,7 +162,8 @@ def main(input_args):
parser.add_argument(
"--silent-backup", action="store_true", default=False,
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(
"--restore", type=str, metavar="path",
help="Restore a previously backup up modoboa instance on a NEW machine. "
@@ -178,7 +188,8 @@ def main(input_args):
sys.exit(1)
utils.success("Welcome to Modoboa installer!\n")
is_config_file_available = utils.check_config_file(
is_config_file_available, outdate_config = utils.check_config_file(
args.configfile, args.interactive, args.upgrade, args.backup, is_restoring)
if not is_config_file_available and (
@@ -186,6 +197,20 @@ def main(input_args):
utils.error("No config file found.")
return
# Check if config is outdated and ask user if it needs to be updated
if is_config_file_available and outdate_config:
answer = utils.user_input("It seems that your config file is outdated. "
"Would you like to update it? (Y/n) ")
if answer.lower().startswith("y"):
config_file_update_complete(utils.update_config(args.configfile))
if not args.stop_after_configfile_check:
answer = utils.user_input("Would you like to stop to review the updated config? (Y/n)")
if answer.lower().startswith("y"):
return
else:
utils.error("You might encounter unexpected errors ! "
"Make sur to update your config before opening an issue!")
if args.stop_after_configfile_check:
return

View File

@@ -1,3 +1,2 @@
codecov
mock
six

View File

@@ -6,8 +6,13 @@ import sys
import tempfile
import unittest
from six import StringIO
from six.moves import configparser
from io import StringIO
from pathlib import Path
try:
import configparser
except ImportError:
import ConfigParser as configparser
try:
from unittest.mock import patch
except ImportError:
@@ -57,6 +62,39 @@ class ConfigFileTestCase(unittest.TestCase):
self.assertEqual(config.get("certificate", "type"), "self-signed")
self.assertEqual(config.get("database", "engine"), "postgres")
@patch("modoboa_installer.utils.user_input")
def test_updating_configfile(self, mock_user_input):
"""Check configfile update mechanism."""
cfgfile_temp = os.path.join(self.workdir, "installer_old.cfg")
out = StringIO()
sys.stdout = out
run.main([
"--stop-after-configfile-check",
"--configfile", cfgfile_temp,
"example.test"])
self.assertTrue(os.path.exists(cfgfile_temp))
# Adding a dummy section
with open(cfgfile_temp, "a") as fp:
fp.write(
"""
[dummy]
weird_old_option = "hey
""")
mock_user_input.side_effect = ["y"]
out = StringIO()
sys.stdout = out
run.main([
"--stop-after-configfile-check",
"--configfile", cfgfile_temp,
"example.test"])
self.assertIn("dummy", out.getvalue())
self.assertTrue(Path(self.workdir).glob("*.old"))
self.assertIn("Update complete",
out.getvalue()
)
@patch("modoboa_installer.utils.user_input")
def test_interactive_mode_letsencrypt(self, mock_user_input):
"""Check interactive mode."""
@@ -92,6 +130,9 @@ class ConfigFileTestCase(unittest.TestCase):
" postwhite spamassassin uwsgi",
out.getvalue()
)
self.assertNotIn("It seems that your config file is outdated.",
out.getvalue()
)
@patch("modoboa_installer.utils.user_input")
def test_upgrade_mode(self, mock_user_input):