Skip to content

Commit c0ae7aa

Browse files
committed
Support docker secrets for sensitive data
Use corresponding ENV variable with _FILE suffix to read argument from file Order of precedence is 1. If ENV variable is set, always value of ENV variable as default 2. If ENV_FILE variable is not falsy, use value to read default from file
1 parent 8b513ca commit c0ae7aa

File tree

3 files changed

+119
-5
lines changed

3 files changed

+119
-5
lines changed

src/configuration/argparse_extensions.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from argparse import ArgumentParser, Namespace
55
from gettext import gettext as _
66
import os
7+
from pathlib import Path
78
from typing import TYPE_CHECKING, Any, override
89

910
if TYPE_CHECKING:
@@ -38,26 +39,56 @@ def _get_help_string(self, action: argparse.Action) -> str | None:
3839
if action.option_strings or action.nargs in defaulting_nargs:
3940
# append default value
4041
_help += _("\n(default: %(default)s)")
41-
# append environment variable
42-
_help += f"\n(environment variable: {action.envvar})"
42+
# append environment variables
43+
if action.envvar:
44+
_help += f"\n(environment variable: {action.envvar})"
45+
if action.file_envvar:
46+
_help += f"\n(file environment variable: {action.file_envvar})"
4347
# whitespace from each line
4448
return "\n".join([m.lstrip() for m in _help.split("\n")])
4549

4650

4751
class EnvDefault(argparse.Action):
52+
"""Argparse action that allows setting a default from environment variable or file."""
53+
4854
def __init__(
4955
self,
5056
envvar: str,
5157
required: bool = True,
58+
try_file: bool = False,
5259
default: str | None = None,
5360
**kwargs: dict[str, Any],
5461
) -> None:
5562
self.envvar = envvar
56-
if os.environ.get(envvar):
57-
default = os.environ[envvar]
63+
self.file_envvar = f"{envvar}_FILE" if try_file else None
64+
65+
envvar_value = os.environ.get(self.envvar, None)
66+
envvar_file_value = (
67+
os.environ.get(self.file_envvar, None) if self.file_envvar else None
68+
)
69+
70+
if envvar_value is not None:
71+
# enviroment value takes precedence
72+
default = envvar_value
73+
elif envvar_file_value:
74+
# if the environment variable is not set, check for a file specified by the environment variable with the _FILE suffix
75+
default_from_file = self._get_default_from_file(envvar_file_value)
76+
if default_from_file:
77+
default = default_from_file
78+
5879
if required and default:
80+
# If the default is set from environment, it should not be required from command line
5981
required = False
60-
super().__init__(default=default, required=required, **kwargs)
82+
super().__init__(required=required, default=default, **kwargs)
83+
84+
def _get_default_from_file(self, file_path: str) -> str | None:
85+
"""Get the default value from the file specified by the environment variable."""
86+
try:
87+
with Path(file_path).open(encoding="utf-8") as f:
88+
return f.read().strip()
89+
except OSError as e:
90+
msg = f"Error reading file {file_path}, specified by environment variable {self.file_envvar}"
91+
raise argparse.ArgumentTypeError(msg) from e
6192

6293
@override
6394
def __call__(

src/configuration/parser.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def __add_mqtt_argument_group(
207207
required=False,
208208
action=EnvDefault,
209209
envvar="MQTT_USER",
210+
try_file=True,
210211
type=str,
211212
)
212213
mqtt.add_argument(
@@ -216,6 +217,7 @@ def __add_mqtt_argument_group(
216217
required=False,
217218
action=EnvDefault,
218219
envvar="MQTT_PASSWORD",
220+
try_file=True,
219221
type=str,
220222
)
221223
mqtt.add_argument(
@@ -289,6 +291,7 @@ def __add_saic_api_argument_group(
289291
required=True,
290292
action=EnvDefault,
291293
envvar="SAIC_USER",
294+
try_file=True,
292295
type=str,
293296
)
294297
saic_api.add_argument(
@@ -299,6 +302,7 @@ def __add_saic_api_argument_group(
299302
required=True,
300303
action=EnvDefault,
301304
envvar="SAIC_PASSWORD",
305+
try_file=True,
302306
type=str,
303307
)
304308
saic_api.add_argument(
@@ -464,6 +468,7 @@ def __add_abrp_argument_group(
464468
required=False,
465469
action=EnvDefault,
466470
envvar="ABRP_API_KEY",
471+
try_file=True,
467472
type=str,
468473
)
469474
abrp_integration.add_argument(
@@ -475,6 +480,7 @@ def __add_abrp_argument_group(
475480
required=False,
476481
action=EnvDefault,
477482
envvar="ABRP_USER_TOKEN",
483+
try_file=True,
478484
type=str,
479485
)
480486
abrp_integration.add_argument(

tests/test_argparse_extensions.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
import pytest
6+
7+
from configuration.argparse_extensions import EnvDefault
8+
9+
10+
class DummyParser(argparse.ArgumentParser):
11+
"""Dummy ArgumentParser for testing purposes."""
12+
13+
def __init__(self) -> None:
14+
super().__init__(add_help=False)
15+
16+
17+
@pytest.fixture(name="mock_envdefault_file")
18+
def setup_fixture_mock_envdefault_file(monkeypatch: pytest.MonkeyPatch) -> None:
19+
"""Mock the _get_default_from_file method to return a fixed value."""
20+
monkeypatch.setattr(
21+
"configuration.argparse_extensions.EnvDefault._get_default_from_file",
22+
lambda *_, **__: "file_env_value",
23+
)
24+
25+
26+
@pytest.fixture(name="mock_env")
27+
def setup_fixture_mock_env(monkeypatch: pytest.MonkeyPatch) -> None: # pylint: disable=unused-argument
28+
"""Mock the environment variable to return a fixed value."""
29+
30+
31+
# pylint: disable-next=unused-argument
32+
def test_envdefault_envvar(
33+
monkeypatch: pytest.MonkeyPatch,
34+
mock_envdefault_file: None, # noqa: ARG001 #pylint: disable=unused-argument
35+
) -> None:
36+
"""Retrieves the value from an environment variable."""
37+
monkeypatch.setenv("TEST_ENV", "env_value")
38+
parser = DummyParser()
39+
parser.add_argument("--test", action=EnvDefault, envvar="TEST_ENV", required=False)
40+
args = parser.parse_args([])
41+
assert args.test == "env_value"
42+
43+
44+
def test_envdefault_file_envvar(
45+
monkeypatch: pytest.MonkeyPatch,
46+
mock_envdefault_file: None, # noqa: ARG001 pylint: disable=unused-argument
47+
) -> None:
48+
"""Retrieve the value from a file specified by an environment variable."""
49+
monkeypatch.setenv("TEST_ENV_FILE", "file_env_value")
50+
parser = DummyParser()
51+
parser.add_argument(
52+
"--test",
53+
action=EnvDefault,
54+
try_file=True,
55+
envvar="TEST_ENV",
56+
required=False,
57+
)
58+
args = parser.parse_args([])
59+
assert args.test == "file_env_value"
60+
61+
62+
def test_envdefault_priority(
63+
monkeypatch: pytest.MonkeyPatch,
64+
mock_envdefault_file: None, # noqa: ARG001 pylint: disable=unused-argument
65+
) -> None:
66+
"""Prioritize environment variable over file."""
67+
monkeypatch.setenv("TEST_ENV", "env_value")
68+
monkeypatch.setenv("TEST_ENV_FILE", "file_env_value")
69+
70+
parser = DummyParser()
71+
parser.add_argument(
72+
"--test",
73+
action=EnvDefault,
74+
try_file=True,
75+
envvar="TEST_ENV",
76+
required=False,
77+
)

0 commit comments

Comments
 (0)