comparison roundup/configuration.py @ 6578:b1f1539c6a31

issue2551182 - ... allow loading values from external file. flake8 cleanups Secrets (passwords, secrets) can specify a file using file:// or file:///. The first line of the file is used as the secret. This allows committing config.ini to a VCS. Following settings are changed: [tracker] secret_key [tracker] jwt_secret [rdbms] password [mail] password details: in roundup/configuration.py: Defined SecretMandatoryOptions and SecretNullableOptions. Converted all secret keys and password to one of the above. Also if [mail] username is defined but [mail] password is not it throws an error at load. Cleaned up a couple of methods whose call signature included: def ...(..., settings={}): settings=None and it is set to empty dict inside the method. Also replace exception.message with str(exception) for python3 compatibility. in test/test_config: changed munge_configini to support changing only within a section, replacing keyword text.
author John Rouillard <rouilj@ieee.org>
date Mon, 03 Jan 2022 22:18:57 -0500
parents c77bd76b57da
children 770503bd211e
comparison
equal deleted inserted replaced
6577:61481d7bbb07 6578:b1f1539c6a31
7 # Roundup if used with Python 2 because it generates unicode objects 7 # Roundup if used with Python 2 because it generates unicode objects
8 # where not expected by the Python code. Thus, a version check is 8 # where not expected by the Python code. Thus, a version check is
9 # used here instead of try/except. 9 # used here instead of try/except.
10 import sys 10 import sys
11 import getopt 11 import getopt
12 import logging, logging.config 12 import errno
13 import logging
14 import logging.config
13 import os 15 import os
14 import re 16 import re
15 import time 17 import time
16 import smtplib 18 import smtplib
17 19
34 ### Exceptions 36 ### Exceptions
35 37
36 38
37 class ConfigurationError(RoundupException): 39 class ConfigurationError(RoundupException):
38 pass 40 pass
41
39 42
40 class ParsingOptionError(ConfigurationError): 43 class ParsingOptionError(ConfigurationError):
41 def __str__(self): 44 def __str__(self):
42 return self.args[0] 45 return self.args[0]
43 46
266 try: 269 try:
267 if config.has_option(self.section, self.setting): 270 if config.has_option(self.section, self.setting):
268 self.set(config.get(self.section, self.setting)) 271 self.set(config.get(self.section, self.setting))
269 except configparser.InterpolationSyntaxError as e: 272 except configparser.InterpolationSyntaxError as e:
270 raise ParsingOptionError( 273 raise ParsingOptionError(
271 _("Error in %(filepath)s with section [%(section)s] at option %(option)s: %(message)s")%{ 274 _("Error in %(filepath)s with section [%(section)s] at "
272 "filepath": self.config.filepath, 275 "option %(option)s: %(message)s") % {
273 "section": e.section, 276 "filepath": self.config.filepath,
274 "option": e.option, 277 "section": e.section,
275 "message": e.message}) 278 "option": e.option,
276 279 "message": str(e)})
277 280
278 281
279 class BooleanOption(Option): 282 class BooleanOption(Option):
280 283
281 """Boolean option: yes or no""" 284 """Boolean option: yes or no"""
417 _val = value.lower() 420 _val = value.lower()
418 if _val in self.allowed: 421 if _val in self.allowed:
419 return _val 422 return _val
420 raise OptionValueError(self, value, self.class_description) 423 raise OptionValueError(self, value, self.class_description)
421 424
425
422 class IndexerOption(Option): 426 class IndexerOption(Option):
423 """Valid options for indexer""" 427 """Valid options for indexer"""
424 428
425 allowed = ['', 'xapian', 'whoosh', 'native'] 429 allowed = ['', 'xapian', 'whoosh', 'native']
426 class_description = "Allowed values: %s" % ', '.join("'%s'" % a 430 class_description = "Allowed values: %s" % ', '.join("'%s'" % a
429 def str2value(self, value): 433 def str2value(self, value):
430 _val = value.lower() 434 _val = value.lower()
431 if _val in self.allowed: 435 if _val in self.allowed:
432 return _val 436 return _val
433 raise OptionValueError(self, value, self.class_description) 437 raise OptionValueError(self, value, self.class_description)
438
434 439
435 class MailAddressOption(Option): 440 class MailAddressOption(Option):
436 441
437 """Email address 442 """Email address
438 443
561 raise OptionValueError(self, value, "Value must not be empty.") 566 raise OptionValueError(self, value, "Value must not be empty.")
562 else: 567 else:
563 return value 568 return value
564 569
565 570
571 class SecretOption(Option):
572 """A string not beginning with file:// or a file starting with file://
573
574 Paths may be either absolute or relative to the HOME.
575 Value for option is the first line in the file.
576 It is mean to store secret information in the config file but
577 allow the config file to be stored in version control without
578 storing the secret there.
579
580 """
581
582 class_description = \
583 "A string that starts with 'file://' is interpreted as a file path \n" \
584 "relative to the tracker home. Using 'file:///' defines an absolute \n" \
585 "path. The first line of the file will be used as the value. Any \n" \
586 "string that does not start with 'file://' is used as is. It \n" \
587 "removes any whitespace at the end of the line, so a newline can \n" \
588 "be put in the file.\n"
589
590 def get(self):
591 _val = Option.get(self)
592 if isinstance(_val, str) and _val.startswith('file://'):
593 filepath = _val[7:]
594 if filepath and not os.path.isabs(filepath):
595 filepath = os.path.join(self.config["HOME"], filepath.strip())
596 try:
597 with open(filepath) as f:
598 _val = f.readline().rstrip()
599 # except FileNotFoundError: py2/py3
600 # compatible version
601 except EnvironmentError as e:
602 if e.errno != errno.ENOENT:
603 raise
604 else:
605 raise OptionValueError(self, _val,
606 "Unable to read value for %s. Error opening "
607 "%s: %s." % (self.name, e.filename, e.args[1]))
608 return self.str2value(_val)
609
610
566 class WebUrlOption(Option): 611 class WebUrlOption(Option):
567 """URL MUST start with http/https scheme and end with '/'""" 612 """URL MUST start with http/https scheme and end with '/'"""
568 613
569 def str2value(self, value): 614 def str2value(self, value):
570 if not value: 615 if not value:
621 666
622 # .get() and class_description are from FilePathOption, 667 # .get() and class_description are from FilePathOption,
623 get = FilePathOption.get 668 get = FilePathOption.get
624 class_description = FilePathOption.class_description 669 class_description = FilePathOption.class_description
625 # everything else taken from NullableOption (inheritance order) 670 # everything else taken from NullableOption (inheritance order)
671
672
673 class SecretMandatoryOption(MandatoryOption, SecretOption):
674 # use get from SecretOption and rest from MandatoryOption
675 get = SecretOption.get
676 class_description = SecretOption.class_description
677
678
679 class SecretNullableOption(NullableOption, SecretOption):
680 # use get from SecretOption and rest from NullableOption
681 get = SecretOption.get
682 class_description = SecretOption.class_description
626 683
627 684
628 class TimezoneOption(Option): 685 class TimezoneOption(Option):
629 686
630 class_description = \ 687 class_description = \
654 """Used by roundup-server to verify http version is set to valid 711 """Used by roundup-server to verify http version is set to valid
655 string.""" 712 string."""
656 713
657 def str2value(self, value): 714 def str2value(self, value):
658 if value not in ["HTTP/1.0", "HTTP/1.1"]: 715 if value not in ["HTTP/1.0", "HTTP/1.1"]:
659 raise OptionValueError(self, value, "Valid vaues for -V or --http_version are: HTTP/1.0, HTTP/1.1") 716 raise OptionValueError(self, value,
717 "Valid vaues for -V or --http_version are: HTTP/1.0, HTTP/1.1")
660 return value 718 return value
661 719
662 720
663 class RegExpOption(Option): 721 class RegExpOption(Option):
664 722
1057 "tracker admin."), 1115 "tracker admin."),
1058 (BooleanOption, "migrate_passwords", "yes", 1116 (BooleanOption, "migrate_passwords", "yes",
1059 "Setting this option makes Roundup migrate passwords with\n" 1117 "Setting this option makes Roundup migrate passwords with\n"
1060 "an insecure password-scheme to a more secure scheme\n" 1118 "an insecure password-scheme to a more secure scheme\n"
1061 "when the user logs in via the web-interface."), 1119 "when the user logs in via the web-interface."),
1062 (MandatoryOption, "secret_key", create_token(), 1120 (SecretMandatoryOption, "secret_key", create_token(),
1063 "A per tracker secret used in etag calculations for\n" 1121 "A per tracker secret used in etag calculations for\n"
1064 "an object. It must not be empty.\n" 1122 "an object. It must not be empty.\n"
1065 "It prevents reverse engineering hidden data in an object\n" 1123 "It prevents reverse engineering hidden data in an object\n"
1066 "by calculating the etag for a sample object. Then modifying\n" 1124 "by calculating the etag for a sample object. Then modifying\n"
1067 "hidden properties until the sample object's etag matches\n" 1125 "hidden properties until the sample object's etag matches\n"
1069 "Changing this changes the etag and invalidates updates by\n" 1127 "Changing this changes the etag and invalidates updates by\n"
1070 "clients. It must be persistent across application restarts.\n" 1128 "clients. It must be persistent across application restarts.\n"
1071 "(Note the default value changes every time\n" 1129 "(Note the default value changes every time\n"
1072 " roundup-admin updateconfig\n" 1130 " roundup-admin updateconfig\n"
1073 "is run, so it must be explicitly set to a non-empty string.\n"), 1131 "is run, so it must be explicitly set to a non-empty string.\n"),
1074 (MandatoryOption, "jwt_secret", "disabled", 1132 (SecretNullableOption, "jwt_secret", "disabled",
1075 "This is used to generate/validate json web tokens (jwt).\n" 1133 "This is used to generate/validate json web tokens (jwt).\n"
1076 "Even if you don't use jwts it must not be empty.\n" 1134 "Even if you don't use jwts it must not be empty.\n"
1077 "If less than 256 bits (32 characters) in length it will\n" 1135 "If less than 256 bits (32 characters) in length it will\n"
1078 "disable use of jwt. Changing this invalidates all jwts\n" 1136 "disable use of jwt. Changing this invalidates all jwts\n"
1079 "issued by the roundup instance requiring *all* users to\n" 1137 "issued by the roundup instance requiring *all* users to\n"
1095 "for MySQL default port number is 3306.\n" 1153 "for MySQL default port number is 3306.\n"
1096 "Leave this option empty to use backend default"), 1154 "Leave this option empty to use backend default"),
1097 (NullableOption, 'user', 'roundup', 1155 (NullableOption, 'user', 'roundup',
1098 "Database user name that Roundup should use.", 1156 "Database user name that Roundup should use.",
1099 ['MYSQL_DBUSER']), 1157 ['MYSQL_DBUSER']),
1100 (NullableOption, 'password', 'roundup', 1158 (SecretNullableOption, 'password', 'roundup',
1101 "Database user password.", 1159 "Database user password.",
1102 ['MYSQL_DBPASSWORD']), 1160 ['MYSQL_DBPASSWORD']),
1103 (NullableOption, 'read_default_file', '~/.my.cnf', 1161 (NullableOption, 'read_default_file', '~/.my.cnf',
1104 "Name of the MySQL defaults file.\n" 1162 "Name of the MySQL defaults file.\n"
1105 "Only used in MySQL connections."), 1163 "Only used in MySQL connections."),
1176 "SMTP mail host that roundup will use to send mail", 1234 "SMTP mail host that roundup will use to send mail",
1177 ["MAILHOST"],), 1235 ["MAILHOST"],),
1178 (Option, "username", "", "SMTP login name.\n" 1236 (Option, "username", "", "SMTP login name.\n"
1179 "Set this if your mail host requires authenticated access.\n" 1237 "Set this if your mail host requires authenticated access.\n"
1180 "If username is not empty, password (below) MUST be set!"), 1238 "If username is not empty, password (below) MUST be set!"),
1181 (Option, "password", NODEFAULT, "SMTP login password.\n" 1239 (SecretMandatoryOption, "password", NODEFAULT, "SMTP login password.\n"
1182 "Set this if your mail host requires authenticated access."), 1240 "Set this if your mail host requires authenticated access."),
1183 (IntegerNumberGeqZeroOption, "port", smtplib.SMTP_PORT, 1241 (IntegerNumberGeqZeroOption, "port", smtplib.SMTP_PORT,
1184 "Default port to send SMTP on.\n" 1242 "Default port to send SMTP on.\n"
1185 "Set this if your mail server runs on a different port."), 1243 "Set this if your mail server runs on a different port."),
1186 (NullableOption, "local_hostname", '', 1244 (NullableOption, "local_hostname", '',
1419 # mapping from option names and aliases to Option instances 1477 # mapping from option names and aliases to Option instances
1420 options = None 1478 options = None
1421 # actual name of the config file. set on load. 1479 # actual name of the config file. set on load.
1422 filepath = os.path.join(HOME, INI_FILE) 1480 filepath = os.path.join(HOME, INI_FILE)
1423 1481
1424 def __init__(self, config_path=None, layout=None, settings={}): 1482 def __init__(self, config_path=None, layout=None, settings=None):
1425 """Initialize confing instance 1483 """Initialize confing instance
1426 1484
1427 Parameters: 1485 Parameters:
1428 config_path: 1486 config_path:
1429 optional directory or file name of the config file. 1487 optional directory or file name of the config file.
1436 settings: 1494 settings:
1437 optional setting overrides (dictionary). 1495 optional setting overrides (dictionary).
1438 The overrides are applied after loading config file. 1496 The overrides are applied after loading config file.
1439 1497
1440 """ 1498 """
1499 if settings is None:
1500 settings = {}
1441 # initialize option containers: 1501 # initialize option containers:
1442 self.sections = [] 1502 self.sections = []
1443 self.section_descriptions = {} 1503 self.section_descriptions = {}
1444 self.section_options = {} 1504 self.section_options = {}
1445 self.options = {} 1505 self.options = {}
1834 1894
1835 # user configs 1895 # user configs
1836 ext = None 1896 ext = None
1837 detectors = None 1897 detectors = None
1838 1898
1839 def __init__(self, home_dir=None, settings={}): 1899 def __init__(self, home_dir=None, settings=None):
1900 if settings is None:
1901 settings = {}
1840 Config.__init__(self, home_dir, layout=SETTINGS, settings=settings) 1902 Config.__init__(self, home_dir, layout=SETTINGS, settings=settings)
1841 # load the config if home_dir given 1903 # load the config if home_dir given
1842 if home_dir is None: 1904 if home_dir is None:
1843 self.init_logging() 1905 self.init_logging()
1844 1906
1918 import textwrap 1980 import textwrap
1919 lang_avail = b2s(xapian.Stem.get_available_languages()) 1981 lang_avail = b2s(xapian.Stem.get_available_languages())
1920 languages = textwrap.fill(_("Valid languages: ") + 1982 languages = textwrap.fill(_("Valid languages: ") +
1921 lang_avail, 75, 1983 lang_avail, 75,
1922 subsequent_indent=" ") 1984 subsequent_indent=" ")
1923 raise OptionValueError( options["INDEXER_LANGUAGE"], 1985 raise OptionValueError(options["INDEXER_LANGUAGE"],
1924 lang, languages) 1986 lang, languages)
1987
1988 if options['MAIL_USERNAME']._value != "":
1989 # require password to be set
1990 if options['MAIL_PASSWORD']._value is NODEFAULT:
1991 raise OptionValueError(options["MAIL_PASSWORD"],
1992 "not defined",
1993 "mail username is set, so this must be defined.")
1925 1994
1926 def load(self, home_dir): 1995 def load(self, home_dir):
1927 """Load configuration from path designated by home_dir argument""" 1996 """Load configuration from path designated by home_dir argument"""
1928 if os.path.isfile(os.path.join(home_dir, self.INI_FILE)): 1997 if os.path.isfile(os.path.join(home_dir, self.INI_FILE)):
1929 self.load_ini(home_dir) 1998 self.load_ini(home_dir)

Roundup Issue Tracker: http://roundup-tracker.org/