Mercurial > p > roundup > code
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) |
