diff --git a/modoboa/admin/tests/test_mapfiles.py b/modoboa/admin/tests/test_mapfiles.py index a6fa41ade..79f424176 100644 --- a/modoboa/admin/tests/test_mapfiles.py +++ b/modoboa/admin/tests/test_mapfiles.py @@ -1,7 +1,12 @@ """Test map files generation.""" +import os + +from django.conf import settings +from django.core.management import call_command from django.test import TestCase +from modoboa.core.utils import parse_map_file from modoboa.lib.test_utils import MapFilesTestCaseMixin @@ -14,3 +19,38 @@ class MapFilesTestCase(MapFilesTestCaseMixin, TestCase): "sql-maintain.cf", "sql-sender-login-mailboxes.cf", "sql-sender-login-mailboxes-extra.cf", "sql-sender-login-aliases.cf" ] + + def test_map_upgrade(self): + """Check that map content is used.""" + dburl = "postgres://user:password@localhost/testdb" + call_command( + "generate_postfix_maps", + "--dburl", dburl, "--destdir", self.workdir) + + # Now upgrade files. Map credentials should be preserved. + call_command("generate_postfix_maps", "--destdir", self.workdir) + for f in self.MAP_FILES: + mapcontent = parse_map_file(os.path.join(self.workdir, f)) + self.assertEqual(mapcontent["user"], "user") + self.assertEqual(mapcontent["password"], "password") + self.assertEqual(mapcontent["dbname"], "testdb") + + # Now force overwrite, credentials should be different + call_command( + "generate_postfix_maps", "--destdir", self.workdir, + "--force-overwrite") + dbsettings = settings.DATABASES["default"] + for f in self.MAP_FILES: + mapcontent = parse_map_file(os.path.join(self.workdir, f)) + self.assertEqual(mapcontent["user"], dbsettings["USER"]) + self.assertEqual(mapcontent["password"], dbsettings["PASSWORD"]) + self.assertEqual(mapcontent["dbname"], dbsettings["NAME"]) + + # Now modify a file manually + path = os.path.join(self.workdir, "sql-domains.cf") + with open(path, "a") as fp: + fp.write("pouet") + call_command("generate_postfix_maps", "--destdir", self.workdir) + with open(path) as fp: + content = fp.read() + self.assertIn("pouet", content) diff --git a/modoboa/core/management/commands/generate_postfix_maps.py b/modoboa/core/management/commands/generate_postfix_maps.py index ae29af77f..bb3e915ab 100644 --- a/modoboa/core/management/commands/generate_postfix_maps.py +++ b/modoboa/core/management/commands/generate_postfix_maps.py @@ -1,5 +1,7 @@ """Management command to generate/update postfix map files.""" +import copy +import hashlib import os import sys @@ -11,6 +13,7 @@ from django.utils import timezone import dj_database_url from ... import signals +from ... import utils MAP_FILE_TEMPLATE = """# This file was generated on {{ date }} by running: # {{ commandline }} @@ -30,6 +33,23 @@ class Command(BaseCommand): parser.add_argument( "--destdir", default=".", help="Directory where files will be created") + parser.add_argument( + "--force-overwrite", action="store_true", default=False, + help="Force overwrite of existing map files") + + def __load_checksums(self, destdir): + """Load existing checksums if possible.""" + self.__checksums_file = os.path.join( + destdir, "modoboa-postfix-maps.chk") + self.__checksums = {} + if not os.path.exists(self.__checksums_file): + return + with open(self.__checksums_file) as fp: + for line in fp: + fname, dbtype, checksum = line.split(":") + self.__checksums[fname.strip()] = { + "dbtype": dbtype, "checksum": checksum.strip() + } def __register_map_files(self): """Load specified applications.""" @@ -39,6 +59,18 @@ class Command(BaseCommand): mapfiles += response[1] return mapfiles + def __check_file(self, path): + """Check if map file has been modified.""" + fname = os.path.basename(path) + condition = ( + not self.__checksums or + fname not in self.__checksums) + if condition: + return True + with open(path) as fp: + checksum = hashlib.md5(fp.read()).hexdigest() + return checksum == self.__checksums[fname]["checksum"] + def get_template(self, dbtype): """Return map file template.""" tplcontent = MAP_FILE_TEMPLATE @@ -80,8 +112,23 @@ query = {{ query|safe }} } return context - def __render_map_file(self, mapobject, destdir, context): + def __render_map_file( + self, mapobject, destdir, context, force_overwrite=False): """Render a map file.""" + fullpath = os.path.join(destdir, mapobject.filename) + if os.path.exists(fullpath) and not force_overwrite: + if not self.__check_file(fullpath): + print( + "Cannot upgrade '{}' map because it has been modified." + .format(mapobject.filename)) + return self.__checksums[mapobject.filename] + mapcontent = utils.parse_map_file(fullpath) + context = copy.deepcopy(context) + context["dbtype"] = self.__checksums[mapobject.filename]["dbtype"] + context["dbuser"] = mapcontent["user"] + context["dbpass"] = mapcontent["password"] + context["dbname"] = mapcontent["dbname"] + context["dbhost"] = mapcontent["hosts"] content = self.get_template(context["dbtype"]).render( Context( dict(context.items(), @@ -90,7 +137,8 @@ query = {{ query|safe }} ) fullpath = os.path.join(destdir, mapobject.filename) with open(fullpath, "w") as fp: - fp.write("{}\n".format(content)) + fp.write(content) + return hashlib.md5(content).hexdigest() def handle(self, *args, **options): """Command entry point.""" @@ -100,6 +148,15 @@ query = {{ query|safe }} os.mkdir(destdir) except OSError: pass + self.__load_checksums(destdir) context = self.get_template_context(options) + checksums = {} for mapobject in mapfiles: - self.__render_map_file(mapobject, destdir, context) + checksum = self.__render_map_file( + mapobject, destdir, context, + force_overwrite=options["force_overwrite"]) + checksums[mapobject.filename] = checksum + with open(self.__checksums_file, "w") as fp: + for fname, checksum in checksums.items(): + fp.write("{}:{}:{}\n".format( + fname, context["dbtype"], checksum)) diff --git a/modoboa/core/utils.py b/modoboa/core/utils.py index 6eb0afa36..371e5b33a 100644 --- a/modoboa/core/utils.py +++ b/modoboa/core/utils.py @@ -11,6 +11,18 @@ from modoboa.lib.api_client import ModoAPIClient from . import models +def parse_map_file(path): + """Parse a postfix map file and return values.""" + content = {} + with open(path) as fp: + for line in fp: + if not line or line.startswith("#"): + continue + name, value = line.split("=", 1) + content[name.strip()] = value.strip() + return content + + def check_for_updates(request): """Check if a new version of Modoboa is available.""" local_config = models.LocalConfig.objects.first()