Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add configuration variable expansion #681

Merged
merged 1 commit into from
Jul 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/configure/mqttwarn.ini.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,29 @@ display them on all XBMC targets:
targets = log:error, xbmc
title = mqttwarn
```

## Variables

You can load option values either from environment variables or file content.
To do this, replace option's value with one of the following:

- `${ENV:FOO}` - Replaces option's value with environment variable `FOO`.
- `${FILE:/path/to/foo.txt}` - Replaces option's value with file contents from
`/path/to/foo.txt`. The file path can also be relative like `${FILE:foo.txt}`
in which case the file is loaded relative to configuration file's location.

The variable pattern can take either form like `$TYPE:NAME` or `${TYPE:NAME}`.
Latter pattern is required when variable name (`NAME`) contains characters that
are not alphanumeric or underscore.

For example:
```ini
[defaults]
username = $ENV:MQTTWARN_USERNAME
password = $ENV:MQTTWARN_PASSWORD

[config:xxx]
targets = {
'targetname1': [ '${FILE:/run/secrets/address.txt}' ],
}
```
74 changes: 71 additions & 3 deletions mqttwarn/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
import codecs
import logging
import os
import re
import sys
import typing as t
from configparser import NoOptionError, NoSectionError, RawConfigParser
from configparser import Interpolation, NoOptionError, NoSectionError, RawConfigParser

from mqttwarn.util import load_functions

Expand All @@ -20,6 +21,72 @@
logger = logging.getLogger(__name__)


def expand_vars(input: str, sources: t.Dict[str, t.Callable[[str], str]]) -> str:
"""
Expand variables in `input` string with values from `sources` dict.

Variables may be in two forms, either $TYPE:KEY or ${TYPE:KEY}. The second form must be used when `KEY` contains
characters other than numbers, alphabets or underscore. Supported `TYPE`s depends on keys of `sources` dict.

The `sources` is a dict where key is name of `TYPE` in the pattern above and value is a function that takes `KEY`
as argument and returns contents of the variable to be expanded.

:return: Input string with variables expanded
"""
expanded = ""
input_index = 0
match = None
# `input` may have multiple variables in form of $TYPE:KEY or ${TYPE:KEY} pattern, iterate through them
for match in re.finditer(r"\$(\w+):(\w+)|\$\{(\w+):([^}]+)\}", input):
var_type = match[1] if match[1] else match[3] # TYPE part in the variable pattern
var_key = match[2] if match[2] else match[4] # KEY part in the variable pattern

if var_type not in sources:
raise KeyError(f"{match[0]}: Variable type '{var_type}' not supported")
source = sources[var_type]

try:
value = source(var_key)
except Exception as ex:
raise KeyError(f"{match[0]}: {str(ex)}") from ex

match_start, match_end = match.span()
expanded += input[input_index:match_start] + value
input_index = match_end

if match:
return expanded + input[input_index:]
return input


class VariableInterpolation(Interpolation):
def __init__(self, configuration_path):
self.configuration_path = configuration_path
self.sources = {
"ENV": self.get_env_variable,
"FILE": self.get_file_contents,
}

def before_get(self, parser, section, option, value, defaults):
return expand_vars(value, self.sources) if type(value) == str else value

def get_env_variable(self, name: str) -> str:
"""
Get environment variable of `name` and return it
"""
return os.environ[name]

def get_file_contents(self, filepath: str) -> str:
"""
Get file contents from `filepath` and return it
"""
if not os.path.isfile(filepath):
# Read file contents relative to path of configuration file if path is relative
filepath = os.path.join(self.configuration_path, filepath)
with open(filepath) as file:
return file.read()


class Config(RawConfigParser):

specials: t.Dict[str, t.Union[bool, None]] = {
Expand All @@ -34,13 +101,14 @@ def __init__(self, configuration_file: t.Optional[str] = None, defaults: t.Optio

self.configuration_path = None

RawConfigParser.__init__(self)
configuration_path = os.path.dirname(configuration_file) if configuration_file else None
RawConfigParser.__init__(self, interpolation=VariableInterpolation(configuration_path))
if configuration_file is not None:
f = codecs.open(configuration_file, "r", encoding="utf-8")
self.read_file(f)
f.close()

self.configuration_path = os.path.dirname(configuration_file)
self.configuration_path = configuration_path

""" set defaults """
self.hostname = "localhost"
Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
configfile_empty_functions = "tests/etc/empty-functions.ini"
configfile_logging_levels = "tests/etc/logging-levels.ini"
configfile_better_addresses = "tests/etc/better-addresses.ini"
configfile_with_variables = "tests/etc/with-variables.ini"
funcfile_good = "tests/etc/functions_good.py"
funcfile_bad = "tests/etc/functions_bad.py"
1 change: 1 addition & 0 deletions tests/etc/password.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
secret-password
28 changes: 28 additions & 0 deletions tests/etc/with-variables.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# (c) 2023 The mqttwarn developers
#
# mqttwarn configuration file for testing variable expansion.
#

; -------
; General
; -------

[defaults]
hostname = $ENV:HOSTNAME
port = $ENV:PORT
username = ${ENV:USERNAME}
password = ${FILE:./password.txt}

; name the service providers you will be using.
launch = file


; --------
; Services
; --------

[config:file]
targets = {
'mylog' : [ '$ENV:LOG_FILE' ],
}
88 changes: 86 additions & 2 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
# (c) 2022 The mqttwarn developers
import os
import re
import ssl
from unittest.mock import Mock, call
from unittest.mock import Mock, call, patch

import pytest

import mqttwarn.configuration
from mqttwarn.configuration import load_configuration
from tests import configfile_better_addresses
from tests import configfile_better_addresses, configfile_with_variables


def test_config_with_ssl():
Expand Down Expand Up @@ -81,3 +83,85 @@ def test_config_better_addresses_pushsafer():
assert apprise_service_targets["nagios"]["device"] == "52|65|78"
assert apprise_service_targets["nagios"]["priority"] == 2
assert apprise_service_targets["tracking"]["device"] == "gs23"


@patch.dict(os.environ, {"HOSTNAME": "example.com", "PORT": "3000", "USERNAME": "bob", "LOG_FILE": "/tmp/out.log"})
def test_config_expand_variables():
"""
Verify reading configuration file expands variables.
"""
config = load_configuration(configfile_with_variables)
assert config.hostname == "example.com"
assert config.port == 3000
assert config.username == "bob"
assert config.password == "secret-password"
assert config.getdict("config:file", "targets")["mylog"][0] == "/tmp/out.log"


@pytest.mark.parametrize(
"input, expected",
[
("my-password", "my-password"),
("$SRC_1:PASSWORD_1", "my-password"),
("$SRC_1:PASSWORD_2", "super-secret"),
("-->$SRC_1:PASSWORD_1<--", "-->my-password<--"),
("$SRC_2:PASSWORD_1", "p4ssw0rd"),
("$SRC_1:PÄSSWÖRD_3", "non-ascii-secret"),
("${SRC_1:PASSWORD_1}", "my-password"),
("${SRC_1:/path/to/password.txt}", "file-contents"),
("${SRC_1:PASSWORD_1} ${SRC_1:PASSWORD_2}", "my-password super-secret"),
("$SRC_1:PASSWORD_1 ${SRC_1:/path/to/password.txt} $SRC_1:PASSWORD_1", "my-password file-contents my-password"),
(
"${SRC_1:/path/to/password.txt} $SRC_1:PASSWORD_1 ${SRC_1:/path/to/password.txt}",
"file-contents my-password file-contents",
),
("/$SRC_1:PASSWORD_1/$SRC_1:PASSWORD_2/foo.txt", "/my-password/super-secret/foo.txt"),
],
)
def test_expand_vars_ok(input, expected):
"""
Verify that `expand_vars` expands variables in configuration.
"""

def create_source(variables):
return lambda name: variables[name]

sources = {
"SRC_1": create_source(
{
"PASSWORD_1": "my-password",
"PASSWORD_2": "super-secret",
"PÄSSWÖRD_3": "non-ascii-secret",
"/path/to/password.txt": "file-contents",
}
),
"SRC_2": create_source(
{
"PASSWORD_1": "p4ssw0rd",
}
),
}
Comment on lines +129 to +143
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRC_1 and SRC_2 are two synthetic variable interpolation types like ENV or FILE, but only used within the test suite?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I'm testing expand_vars() function here directly, bypassing concrete implementation of ENV and FILE sources, so I've replaced them with these fakes.

expanded = mqttwarn.configuration.expand_vars(input, sources)
assert expanded == expected


def test_expand_vars_variable_type_not_supported():
"""
Verify that `expand_vars` raises error when variable type is not supported.
"""
with pytest.raises(
KeyError, match=re.escape("$DOES_NOT_EXIST:VARIABLE: Variable type 'DOES_NOT_EXIST' not supported")
):
mqttwarn.configuration.expand_vars("-->$DOES_NOT_EXIST:VARIABLE<--", {})


def test_expand_vars_variable_not_found():
"""
Verify that `expand_vars` raises error when variable is not in source.
"""

def empty_source(name):
raise KeyError("Variable not found")

with pytest.raises(KeyError, match=re.escape("$SRC_1:VARIABLE: 'Variable not found'")):
mqttwarn.configuration.expand_vars("-->$SRC_1:VARIABLE<--", {"SRC_1": empty_source})
Comment on lines +166 to +167
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If my statement above is true, we may have a leak in the suite between test functions. I.e., the types SRC_1 and SRC_2 are registered within one test function, but used within another. a) What if those would run in a different order? b) Will it still work when exclusively running this function like pytest -k test_expand_vars_variable_not_found?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These test functions shouldn't affect each other as there is no global state used in expand_vars(). It doesn't have any side-effect when called.

Here in this test function we're passing in a new dict of sources {"SRC_1": empty_source} which is not same dict as the sources in test_expand_vars_ok() test function.

I tried moving test_expand_vars_variable_not_found() before test_expand_vars_ok() but it passes still fine:

[nix-shell:~/mqttwarn]$ pytest --no-header --disable-warnings --no-cov -k test_expand_vars
=================================================== test session starts ===================================================
collected 318 items / 304 deselected / 1 skipped / 14 selected                                                            

tests/test_configuration.py::test_expand_vars_variable_not_found PASSED                                             [  7%]
tests/test_configuration.py::test_expand_vars_ok[my-password-my-password] PASSED                                    [ 14%]
tests/test_configuration.py::test_expand_vars_ok[$SRC_1:PASSWORD_1-my-password] PASSED                              [ 21%]
tests/test_configuration.py::test_expand_vars_ok[$SRC_1:PASSWORD_2-super-secret] PASSED                             [ 28%]
tests/test_configuration.py::test_expand_vars_ok[-->$SRC_1:PASSWORD_1<----->my-password<--] PASSED                  [ 35%]
tests/test_configuration.py::test_expand_vars_ok[$SRC_2:PASSWORD_1-p4ssw0rd] PASSED                                 [ 42%]
tests/test_configuration.py::test_expand_vars_ok[$SRC_1:P\xc4SSW\xd6RD_3-non-ascii-secret] PASSED                   [ 50%]
tests/test_configuration.py::test_expand_vars_ok[${SRC_1:PASSWORD_1}-my-password] PASSED                            [ 57%]
tests/test_configuration.py::test_expand_vars_ok[${SRC_1:/path/to/password.txt}-file-contents] PASSED               [ 64%]
tests/test_configuration.py::test_expand_vars_ok[${SRC_1:PASSWORD_1} ${SRC_1:PASSWORD_2}-my-password super-secret] PASSED [ 71%]
tests/test_configuration.py::test_expand_vars_ok[$SRC_1:PASSWORD_1 ${SRC_1:/path/to/password.txt} $SRC_1:PASSWORD_1-my-password file-contents my-password] PASSED [ 78%]
tests/test_configuration.py::test_expand_vars_ok[${SRC_1:/path/to/password.txt} $SRC_1:PASSWORD_1 ${SRC_1:/path/to/password.txt}-file-contents my-password file-contents] PASSED [ 85%]
tests/test_configuration.py::test_expand_vars_ok[/$SRC_1:PASSWORD_1/$SRC_1:PASSWORD_2/foo.txt-/my-password/super-secret/foo.txt] PASSED [ 92%]
tests/test_configuration.py::test_expand_vars_variable_type_not_supported PASSED                                    [100%]

================================================= short test summary info =================================================
SKIPPED [1] tests/services/test_apns.py:11: The `apns` package is not ready for Python3
================================ 14 passed, 1 skipped, 304 deselected, 1 warning in 1.06s =================================

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, probably missed to spot the point. Thanks for clarifying!

Loading