Skip to content

Commit c26264c

Browse files
committed
Support docker secrets for sensitive data
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 Use corresponding ENV variable with _FILE suffix to read argument from file
1 parent 8b513ca commit c26264c

File tree

3 files changed

+120
-5
lines changed

3 files changed

+120
-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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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:
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, mock_envdefault_file: None
34+
) -> None:
35+
"""Retrieves the value from an environment variable."""
36+
monkeypatch.setenv("TEST_ENV", "env_value")
37+
parser = DummyParser()
38+
parser.add_argument("--test", action=EnvDefault, envvar="TEST_ENV", required=False)
39+
args = parser.parse_args([])
40+
assert args.test == "env_value"
41+
42+
43+
def test_envdefault_file_envvar(
44+
monkeypatch: pytest.MonkeyPatch,
45+
mock_envdefault_file: None, # noqa: ARG001 pylint: disable=unused-argument
46+
) -> None:
47+
"""Retrieve the value from a file specified by an environment variable."""
48+
monkeypatch.setenv("TEST_ENV_FILE", "file_env_value")
49+
parser = DummyParser()
50+
parser.add_argument(
51+
"--test",
52+
action=EnvDefault,
53+
try_file=True,
54+
envvar="TEST_ENV",
55+
required=False,
56+
)
57+
args = parser.parse_args([])
58+
assert args.test == "file_env_value"
59+
60+
61+
def test_envdefault_priority(
62+
monkeypatch: pytest.MonkeyPatch,
63+
mock_envdefault_file: None, # noqa: ARG001 pylint: disable=unused-argument
64+
) -> None:
65+
"""Prioritize environment variable over file."""
66+
# envvar > _FILE > docker secret
67+
68+
monkeypatch.setenv("TEST_ENV", "env_value")
69+
monkeypatch.setenv("TEST_ENV_FILE", "file_env_value")
70+
71+
parser = DummyParser()
72+
parser.add_argument(
73+
"--test",
74+
action=EnvDefault,
75+
try_file=True,
76+
envvar="TEST_ENV",
77+
required=False,
78+
)

0 commit comments

Comments
 (0)