yaml-secrets 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yaml_secrets-0.1.0/LICENSE +21 -0
- yaml_secrets-0.1.0/PKG-INFO +92 -0
- yaml_secrets-0.1.0/README.md +77 -0
- yaml_secrets-0.1.0/pyproject.toml +22 -0
- yaml_secrets-0.1.0/setup.cfg +4 -0
- yaml_secrets-0.1.0/src/yaml_secrets/__init__.py +43 -0
- yaml_secrets-0.1.0/src/yaml_secrets/loader.py +203 -0
- yaml_secrets-0.1.0/src/yaml_secrets/vault.py +214 -0
- yaml_secrets-0.1.0/src/yaml_secrets.egg-info/PKG-INFO +92 -0
- yaml_secrets-0.1.0/src/yaml_secrets.egg-info/SOURCES.txt +12 -0
- yaml_secrets-0.1.0/src/yaml_secrets.egg-info/dependency_links.txt +1 -0
- yaml_secrets-0.1.0/src/yaml_secrets.egg-info/requires.txt +6 -0
- yaml_secrets-0.1.0/src/yaml_secrets.egg-info/top_level.txt +1 -0
- yaml_secrets-0.1.0/tests/test_yaml_secrets.py +179 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tylerlhess
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yaml-secrets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in PyYAML replacement with Vault/OpenBao secret substitution
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Requires-Dist: jinja2>=3.0
|
|
11
|
+
Requires-Dist: hvac>=1.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# yaml_secrets
|
|
17
|
+
|
|
18
|
+
Drop-in PyYAML replacement with secret substitution from HashiCorp Vault or OpenBao.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd configvault
|
|
24
|
+
python3 -m venv .venv
|
|
25
|
+
.venv/bin/pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Replace `import yaml` with `import yaml_secrets as yaml` — nothing else changes.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import yaml_secrets as yaml # or: import vault_yaml as yaml / openbao_yaml as yaml
|
|
34
|
+
|
|
35
|
+
with open("config.yaml") as f:
|
|
36
|
+
cfg = yaml.safe_load(f)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Template syntax
|
|
40
|
+
|
|
41
|
+
Three equivalent ways to reference a secret:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
# Jinja2 function (most common)
|
|
45
|
+
api_key: "{{ vault('secret/app/db', 'api_key') }}"
|
|
46
|
+
|
|
47
|
+
# YAML tag shortcut
|
|
48
|
+
api_key: !vault secret/app/db#api_key
|
|
49
|
+
|
|
50
|
+
# Jinja2 filter
|
|
51
|
+
api_key: "{{ 'secret/app/db#api_key' | vault }}"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Any Jinja2 expression works:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
user: "{{ env.HOME }}/user"
|
|
58
|
+
region: "{{ region }}"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Set environment variables before importing yaml_secrets:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# HashiCorp Vault
|
|
67
|
+
export VAULT_ADDR=https://vault.example.com:8200
|
|
68
|
+
export VAULT_TOKEN=hvs.xxxxx
|
|
69
|
+
|
|
70
|
+
# OpenBao (same API, different env prefix)
|
|
71
|
+
export OPENBAO_ADDR=https://openbao.example.com:8200
|
|
72
|
+
export OPENBAO_TOKEN=root-token-xxxxx
|
|
73
|
+
|
|
74
|
+
# Local mock (no server required — for dev/tests)
|
|
75
|
+
export BAO_MOCK_SECRETS=/path/to/mock-secrets.yaml
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Mock file format:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
secret/app/db:
|
|
82
|
+
password: hunter2
|
|
83
|
+
host: db.internal
|
|
84
|
+
secret/app/api:
|
|
85
|
+
api_key: sk-live-123
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
.venv/bin/python -m pytest tests/ -v
|
|
92
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# yaml_secrets
|
|
2
|
+
|
|
3
|
+
Drop-in PyYAML replacement with secret substitution from HashiCorp Vault or OpenBao.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd configvault
|
|
9
|
+
python3 -m venv .venv
|
|
10
|
+
.venv/bin/pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Replace `import yaml` with `import yaml_secrets as yaml` — nothing else changes.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import yaml_secrets as yaml # or: import vault_yaml as yaml / openbao_yaml as yaml
|
|
19
|
+
|
|
20
|
+
with open("config.yaml") as f:
|
|
21
|
+
cfg = yaml.safe_load(f)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Template syntax
|
|
25
|
+
|
|
26
|
+
Three equivalent ways to reference a secret:
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
# Jinja2 function (most common)
|
|
30
|
+
api_key: "{{ vault('secret/app/db', 'api_key') }}"
|
|
31
|
+
|
|
32
|
+
# YAML tag shortcut
|
|
33
|
+
api_key: !vault secret/app/db#api_key
|
|
34
|
+
|
|
35
|
+
# Jinja2 filter
|
|
36
|
+
api_key: "{{ 'secret/app/db#api_key' | vault }}"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Any Jinja2 expression works:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
user: "{{ env.HOME }}/user"
|
|
43
|
+
region: "{{ region }}"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
Set environment variables before importing yaml_secrets:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# HashiCorp Vault
|
|
52
|
+
export VAULT_ADDR=https://vault.example.com:8200
|
|
53
|
+
export VAULT_TOKEN=hvs.xxxxx
|
|
54
|
+
|
|
55
|
+
# OpenBao (same API, different env prefix)
|
|
56
|
+
export OPENBAO_ADDR=https://openbao.example.com:8200
|
|
57
|
+
export OPENBAO_TOKEN=root-token-xxxxx
|
|
58
|
+
|
|
59
|
+
# Local mock (no server required — for dev/tests)
|
|
60
|
+
export BAO_MOCK_SECRETS=/path/to/mock-secrets.yaml
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Mock file format:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
secret/app/db:
|
|
67
|
+
password: hunter2
|
|
68
|
+
host: db.internal
|
|
69
|
+
secret/app/api:
|
|
70
|
+
api_key: sk-live-123
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Testing
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
.venv/bin/python -m pytest tests/ -v
|
|
77
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "yaml-secrets"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Drop-in PyYAML replacement with Vault/OpenBao secret substitution"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"pyyaml>=6.0",
|
|
10
|
+
"jinja2>=3.0",
|
|
11
|
+
"hvac>=1.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=7.0"]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools>=68"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""yaml_secrets — drop-in YAML with secret substitution from HashiCorp Vault or OpenBao."""
|
|
2
|
+
|
|
3
|
+
from yaml_secrets.loader import (
|
|
4
|
+
SafeLoader,
|
|
5
|
+
Loader,
|
|
6
|
+
FullLoader,
|
|
7
|
+
UnsafeLoader,
|
|
8
|
+
CSafeLoader,
|
|
9
|
+
CLoader,
|
|
10
|
+
CFullLoader,
|
|
11
|
+
dump,
|
|
12
|
+
dump_all,
|
|
13
|
+
safe_dump,
|
|
14
|
+
safe_dump_all,
|
|
15
|
+
load,
|
|
16
|
+
safe_load,
|
|
17
|
+
load_all,
|
|
18
|
+
safe_load_all,
|
|
19
|
+
)
|
|
20
|
+
from yaml_secrets.vault import SecretClient, SecretError, SecretSettings
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"SecretClient",
|
|
24
|
+
"SecretError",
|
|
25
|
+
"SecretSettings",
|
|
26
|
+
"CFullLoader",
|
|
27
|
+
"CLoader",
|
|
28
|
+
"CSafeLoader",
|
|
29
|
+
"FullLoader",
|
|
30
|
+
"Loader",
|
|
31
|
+
"SafeLoader",
|
|
32
|
+
"UnsafeLoader",
|
|
33
|
+
"dump",
|
|
34
|
+
"dump_all",
|
|
35
|
+
"load",
|
|
36
|
+
"load_all",
|
|
37
|
+
"safe_dump",
|
|
38
|
+
"safe_dump_all",
|
|
39
|
+
"safe_load",
|
|
40
|
+
"safe_load_all",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Drop-in PyYAML-compatible loaders with secret substitution.
|
|
2
|
+
|
|
3
|
+
Usage is identical to PyYAML::
|
|
4
|
+
|
|
5
|
+
import yaml_secrets as yaml
|
|
6
|
+
cfg = yaml.safe_load(open("config.yaml"))
|
|
7
|
+
|
|
8
|
+
Templates use Jinja2 syntax::
|
|
9
|
+
|
|
10
|
+
api_key: "{{ vault('secret/app/db', 'api_key') }}"
|
|
11
|
+
# or the shorter tag form:
|
|
12
|
+
api_key: !vault secret/app/db#api_key
|
|
13
|
+
# or the filter form:
|
|
14
|
+
api_key: "{{ 'secret/app/db#api_key' | vault }}"
|
|
15
|
+
|
|
16
|
+
Any Jinja2 expression works — ``{{ env.HOME }}``, ``{{ region }}`` — as long as
|
|
17
|
+
it is a complete value. Jinja does not split string values, so keep the
|
|
18
|
+
template as the whole YAML value.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, BinaryIO, IO, TextIO
|
|
27
|
+
|
|
28
|
+
import yaml as _pyyaml
|
|
29
|
+
from jinja2 import Environment, BaseLoader, StrictUndefined, Undefined
|
|
30
|
+
|
|
31
|
+
from yaml_secrets.vault import SecretClient, SecretError, SecretSettings
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"SafeLoader",
|
|
35
|
+
"Loader",
|
|
36
|
+
"FullLoader",
|
|
37
|
+
"UnsafeLoader",
|
|
38
|
+
"CSafeLoader",
|
|
39
|
+
"CLoader",
|
|
40
|
+
"CFullLoader",
|
|
41
|
+
"dump",
|
|
42
|
+
"dump_all",
|
|
43
|
+
"safe_dump",
|
|
44
|
+
"safe_dump_all",
|
|
45
|
+
"load",
|
|
46
|
+
"safe_load",
|
|
47
|
+
"load_all",
|
|
48
|
+
"safe_load_all",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# Jinja delimiters kept as class attributes so users can rebind them.
|
|
52
|
+
BLOCK_START = "{{"
|
|
53
|
+
BLOCK_END = "}}"
|
|
54
|
+
STATEMENT_START = "{%"
|
|
55
|
+
STATEMENT_END = "%}"
|
|
56
|
+
COMMENT_START = "{#"
|
|
57
|
+
COMMENT_END = "#}"
|
|
58
|
+
|
|
59
|
+
_VAULT_TAG_RE = re.compile(
|
|
60
|
+
r"^(?P<indent>[ \t]*)(?P<key>[^\s:]+)\s*:\s*!vault\s+(?P<ref>\S+)$",
|
|
61
|
+
re.MULTILINE,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_env(client: SecretClient, strict: bool = True) -> Environment:
|
|
66
|
+
env = Environment(
|
|
67
|
+
loader=BaseLoader(),
|
|
68
|
+
undefined=StrictUndefined if strict else Undefined,
|
|
69
|
+
autoescape=False,
|
|
70
|
+
keep_trailing_newline=True,
|
|
71
|
+
trim_blocks=True,
|
|
72
|
+
lstrip_blocks=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def vault(path: str, key: str | None = None, default: Any = ...) -> Any:
|
|
76
|
+
if key is None:
|
|
77
|
+
return client.read_secret(path)
|
|
78
|
+
return client.get(path, key, default=default)
|
|
79
|
+
|
|
80
|
+
def vault_get(path: str) -> dict[str, Any]:
|
|
81
|
+
return client.read_secret(path)
|
|
82
|
+
|
|
83
|
+
def vault_filter(path_and_key: str, default: Any = ...) -> Any:
|
|
84
|
+
if "#" not in path_and_key:
|
|
85
|
+
raise SecretError(f"vault filter needs 'path#key', got: {path_and_key!r}")
|
|
86
|
+
path, key = path_and_key.split("#", 1)
|
|
87
|
+
return client.get(path.strip(), key.strip(), default=default)
|
|
88
|
+
|
|
89
|
+
env.globals["vault"] = vault
|
|
90
|
+
env.globals["vault_get"] = vault_get
|
|
91
|
+
env.filters["vault"] = vault_filter
|
|
92
|
+
return env
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _expand_vault_tags(text: str) -> str:
|
|
96
|
+
"""Rewrite ``key: !vault path#field`` to Jinja before rendering."""
|
|
97
|
+
|
|
98
|
+
def repl(match: re.Match[str]) -> str:
|
|
99
|
+
indent = match.group("indent")
|
|
100
|
+
key = match.group("key")
|
|
101
|
+
ref = match.group("ref").strip().strip("'\"")
|
|
102
|
+
if "#" not in ref:
|
|
103
|
+
raise SecretError(f"!vault needs 'path#key', got: {ref!r}")
|
|
104
|
+
path, field = ref.split("#", 1)
|
|
105
|
+
expr = "{{ vault('%s', '%s') }}" % (path.strip(), field.strip())
|
|
106
|
+
return f"{indent}{key}: {expr}"
|
|
107
|
+
|
|
108
|
+
return _VAULT_TAG_RE.sub(repl, text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _SecretMixin:
|
|
112
|
+
"""Mixin that renders Jinja2 + expands !vault tags before handing off to
|
|
113
|
+
the underlying PyYAML loader."""
|
|
114
|
+
|
|
115
|
+
strict: bool = True
|
|
116
|
+
|
|
117
|
+
def __init__(self, stream: IO[str] | TextIO | BinaryIO | str | os.PathLike[str]) -> None:
|
|
118
|
+
if isinstance(stream, (str, os.PathLike)):
|
|
119
|
+
raw = Path(stream).read_text(encoding="utf-8")
|
|
120
|
+
else:
|
|
121
|
+
raw = stream.read() # type: ignore[union-attr]
|
|
122
|
+
|
|
123
|
+
client = SecretClient()
|
|
124
|
+
env = _build_env(client, strict=self.strict)
|
|
125
|
+
rendered = env.from_string(_expand_vault_tags(raw)).render(env=os.environ)
|
|
126
|
+
|
|
127
|
+
import io
|
|
128
|
+
|
|
129
|
+
super().__init__(io.StringIO(rendered))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SafeLoader(_SecretMixin, _pyyaml.SafeLoader):
|
|
133
|
+
"""Drop-in replacement for ``yaml.SafeLoader`` with Vault substitution."""
|
|
134
|
+
|
|
135
|
+
strict = True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Loader(_SecretMixin, _pyyaml.Loader):
|
|
139
|
+
strict = True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class FullLoader(_SecretMixin, _pyyaml.FullLoader):
|
|
143
|
+
strict = True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class UnsafeLoader(_SecretMixin, _pyyaml.UnsafeLoader):
|
|
147
|
+
strict = True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# C-Loader variants map to the pure-Python loaders (functionally identical).
|
|
151
|
+
CSafeLoader = SafeLoader
|
|
152
|
+
CLoader = Loader
|
|
153
|
+
CFullLoader = FullLoader
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _load_with(
|
|
157
|
+
LoaderCls: type,
|
|
158
|
+
stream: IO[str] | TextIO | BinaryIO | str | os.PathLike[str],
|
|
159
|
+
Loader: type | None = None,
|
|
160
|
+
) -> Any:
|
|
161
|
+
obj = LoaderCls(stream)
|
|
162
|
+
return obj.get_single_data()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _load_all_with(
|
|
166
|
+
LoaderCls: type,
|
|
167
|
+
stream: IO[str] | TextIO | BinaryIO | str | os.PathLike[str],
|
|
168
|
+
Loader: type | None = None,
|
|
169
|
+
):
|
|
170
|
+
obj = LoaderCls(stream)
|
|
171
|
+
return obj.get_data()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def safe_load(stream):
|
|
175
|
+
return _load_with(SafeLoader, stream)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load(stream, Loader=None):
|
|
179
|
+
return _load_with(Loader or Loader, stream)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def safe_load_all(stream):
|
|
183
|
+
return _load_all_with(SafeLoader, stream)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_all(stream, Loader=None):
|
|
187
|
+
return _load_all_with(Loader, stream)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def safe_dump(data, stream=None, **kwargs):
|
|
191
|
+
return _pyyaml.safe_dump(data, stream, **kwargs)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def safe_dump_all(data, stream=None, **kwargs):
|
|
195
|
+
return _pyyaml.safe_dump_all(data, stream, **kwargs)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def dump(data, stream=None, **kwargs):
|
|
199
|
+
return _pyyaml.dump(data, stream, **kwargs)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def dump_all(data, stream=None, **kwargs):
|
|
203
|
+
return _pyyaml.dump_all(data, stream, **kwargs)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Secret backend for Vault and OpenBao.
|
|
2
|
+
|
|
3
|
+
Works with zero configuration: it auto-discovers a running Vault or OpenBao from
|
|
4
|
+
environment variables. If none is reachable, lookups return defaults or raise
|
|
5
|
+
clear errors — no server is ever required just to import the library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SecretError(RuntimeError):
|
|
17
|
+
"""Raised when a secret lookup fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _env(name: str, default: str = "") -> str:
|
|
21
|
+
return os.environ.get(name, default)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SecretSettings:
|
|
26
|
+
"""Connection settings.
|
|
27
|
+
|
|
28
|
+
OpenBao is detected automatically when ``BOPENBAO_ADDR`` or ``OPENBAO_ADDR``
|
|
29
|
+
is set; otherwise standard ``VAULT_ADDR`` / ``VAULT_TOKEN`` variables are
|
|
30
|
+
used. Both servers share the KV API, so no code paths differ between them.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
addr: str = field(
|
|
34
|
+
default_factory=lambda: _env("OPENBAO_ADDR", _env("VAULT_ADDR", _env("BAO_ADDR")))
|
|
35
|
+
)
|
|
36
|
+
token: str = field(
|
|
37
|
+
default_factory=lambda: _env("OPENBAO_TOKEN", _env("VAULT_TOKEN", _env("BAO_TOKEN")))
|
|
38
|
+
)
|
|
39
|
+
namespace: str = field(
|
|
40
|
+
default_factory=lambda: _env("OPENBAO_NAMESPACE", _env("VAULT_NAMESPACE"))
|
|
41
|
+
)
|
|
42
|
+
role_id: str = field(
|
|
43
|
+
default_factory=lambda: _env("OPENBAO_ROLE_ID", _env("VAULT_ROLE_ID"))
|
|
44
|
+
)
|
|
45
|
+
secret_id: str = field(
|
|
46
|
+
default_factory=lambda: _env("OPENBAO_SECRET_ID", _env("VAULT_SECRET_ID"))
|
|
47
|
+
)
|
|
48
|
+
kv_mount: str = field(
|
|
49
|
+
default_factory=lambda: _env("OPENBAO_KV_MOUNT", _env("VAULT_KV_MOUNT", "secret"))
|
|
50
|
+
)
|
|
51
|
+
kv_version: int = field(
|
|
52
|
+
default_factory=lambda: int(
|
|
53
|
+
_env("OPENBAO_KV_VERSION", _env("VAULT_KV_VERSION", "2")) or 2
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
cache_ttl_seconds: float = field(
|
|
57
|
+
default_factory=lambda: float(_env("BAO_CACHE_TTL", "60") or 60)
|
|
58
|
+
)
|
|
59
|
+
# Local fallback map for offline dev/tests. JSON or YAML, path -> {key: value}.
|
|
60
|
+
mock_secrets_path: str = field(
|
|
61
|
+
default_factory=lambda: _env("BAO_MOCK_SECRETS", _env("AICONF_MOCK_SECRETS"))
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def backend(self) -> str:
|
|
66
|
+
return "openbao" if _env("OPENBAO_ADDR", _env("BOPENBAO_ADDR")) else "vault"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SecretClient:
|
|
70
|
+
"""Read KV secrets from Vault or OpenBao with local caching.
|
|
71
|
+
|
|
72
|
+
Usage is identical for both servers::
|
|
73
|
+
|
|
74
|
+
client = SecretClient()
|
|
75
|
+
value = client.get("secret/app/db", "password")
|
|
76
|
+
whole = client.read_secret("secret/app/db")
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, settings: SecretSettings | None = None) -> None:
|
|
80
|
+
self.settings = settings or SecretSettings()
|
|
81
|
+
self._client: Any | None = None
|
|
82
|
+
self._cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
83
|
+
self._mock: dict[str, dict[str, Any]] | None = None
|
|
84
|
+
|
|
85
|
+
if self.settings.mock_secrets_path:
|
|
86
|
+
self._mock = self._load_mock(self.settings.mock_secrets_path)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _load_mock(path: str) -> dict[str, dict[str, Any]]:
|
|
90
|
+
import json
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
|
|
93
|
+
import yaml
|
|
94
|
+
|
|
95
|
+
file_path = Path(path)
|
|
96
|
+
if not file_path.exists():
|
|
97
|
+
return {}
|
|
98
|
+
text = file_path.read_text(encoding="utf-8")
|
|
99
|
+
if not text.strip():
|
|
100
|
+
return {}
|
|
101
|
+
data = yaml.safe_load(text) if path.endswith((".yaml", ".yml")) else json.loads(text)
|
|
102
|
+
if data is None:
|
|
103
|
+
return {}
|
|
104
|
+
if not isinstance(data, dict):
|
|
105
|
+
raise SecretError(f"Mock secrets file must be a mapping: {path}")
|
|
106
|
+
return {str(k): dict(v) for k, v in data.items()}
|
|
107
|
+
|
|
108
|
+
def _ensure_client(self) -> Any:
|
|
109
|
+
if self._mock is not None:
|
|
110
|
+
return None
|
|
111
|
+
if self._client is not None:
|
|
112
|
+
return self._client
|
|
113
|
+
|
|
114
|
+
if not self.settings.addr:
|
|
115
|
+
raise SecretError(
|
|
116
|
+
"No Vault/OpenBao server configured. Set VAULT_ADDR (or OPENBAO_ADDR) "
|
|
117
|
+
"and VAULT_TOKEN (or OPENBAO_TOKEN), or use BAO_MOCK_SECRETS for local mocks."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
import hvac
|
|
121
|
+
|
|
122
|
+
client = hvac.Client(url=self.settings.addr, namespace=self.settings.namespace or None)
|
|
123
|
+
|
|
124
|
+
if self.settings.token:
|
|
125
|
+
client.token = self.settings.token
|
|
126
|
+
elif self.settings.role_id and self.settings.secret_id:
|
|
127
|
+
client.auth.approle.login(
|
|
128
|
+
role_id=self.settings.role_id,
|
|
129
|
+
secret_id=self.settings.secret_id,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
raise SecretError("No auth configured. Set VAULT_TOKEN or VAULT_ROLE_ID + VAULT_SECRET_ID.")
|
|
133
|
+
|
|
134
|
+
if not client.is_authenticated():
|
|
135
|
+
raise SecretError("Authentication failed.")
|
|
136
|
+
|
|
137
|
+
self._client = client
|
|
138
|
+
return client
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _normalize_path(path: str, mount: str, kv_version: int) -> tuple[str, str]:
|
|
142
|
+
"""Return (mount, secret_path) for a Vault path."""
|
|
143
|
+
path = path.strip().strip("/")
|
|
144
|
+
mount = mount.strip("/")
|
|
145
|
+
if path.startswith(f"{mount}/"):
|
|
146
|
+
rest = path[len(mount) + 1 :]
|
|
147
|
+
if kv_version == 2 and rest.startswith("data/"):
|
|
148
|
+
rest = rest[5:]
|
|
149
|
+
return mount, rest
|
|
150
|
+
return mount, path
|
|
151
|
+
|
|
152
|
+
def read_secret(self, path: str) -> dict[str, Any]:
|
|
153
|
+
"""Read all key/value pairs at a KV path."""
|
|
154
|
+
now = time.monotonic()
|
|
155
|
+
cached = self._cache.get(path)
|
|
156
|
+
if cached and (now - cached[0]) < self.settings.cache_ttl_seconds:
|
|
157
|
+
return dict(cached[1])
|
|
158
|
+
|
|
159
|
+
if self._mock is not None:
|
|
160
|
+
data = self._mock.get(path) or self._mock.get(path.strip("/"))
|
|
161
|
+
if data is None:
|
|
162
|
+
mount, secret_path = self._normalize_path(
|
|
163
|
+
path, self.settings.kv_mount, self.settings.kv_version
|
|
164
|
+
)
|
|
165
|
+
data = self._mock.get(secret_path) or self._mock.get(f"{mount}/{secret_path}")
|
|
166
|
+
if data is None:
|
|
167
|
+
raise SecretError(f"Secret not found: {path}")
|
|
168
|
+
self._cache[path] = (now, dict(data))
|
|
169
|
+
return dict(data)
|
|
170
|
+
|
|
171
|
+
client = self._ensure_client()
|
|
172
|
+
mount, secret_path = self._normalize_path(
|
|
173
|
+
path, self.settings.kv_mount, self.settings.kv_version
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
if self.settings.kv_version == 2:
|
|
178
|
+
response = client.secrets.kv.v2.read_secret_version(
|
|
179
|
+
path=secret_path, mount_point=mount
|
|
180
|
+
)
|
|
181
|
+
data = response["data"]["data"]
|
|
182
|
+
else:
|
|
183
|
+
response = client.secrets.kv.v1.read_secret(
|
|
184
|
+
path=secret_path, mount_point=mount
|
|
185
|
+
)
|
|
186
|
+
data = response["data"]
|
|
187
|
+
except Exception as exc: # noqa: BLE001
|
|
188
|
+
raise SecretError(f"Failed to read '{path}': {exc}") from exc
|
|
189
|
+
|
|
190
|
+
if not isinstance(data, dict):
|
|
191
|
+
raise SecretError(f"Unexpected secret payload at '{path}'")
|
|
192
|
+
|
|
193
|
+
self._cache[path] = (now, dict(data))
|
|
194
|
+
return dict(data)
|
|
195
|
+
|
|
196
|
+
def get(
|
|
197
|
+
self, path: str, key: str, default: Any = ..., *, required: bool = True
|
|
198
|
+
) -> Any:
|
|
199
|
+
"""Read one key from a secret path.
|
|
200
|
+
|
|
201
|
+
``default=...`` (ellipsis) requires the key. Pass ``required=False`` to
|
|
202
|
+
return None when the key is missing.
|
|
203
|
+
"""
|
|
204
|
+
data = self.read_secret(path)
|
|
205
|
+
if key not in data:
|
|
206
|
+
if default is not ...:
|
|
207
|
+
return default
|
|
208
|
+
if not required:
|
|
209
|
+
return None
|
|
210
|
+
raise SecretError(f"Key '{key}' not found at '{path}'")
|
|
211
|
+
return data[key]
|
|
212
|
+
|
|
213
|
+
def clear_cache(self) -> None:
|
|
214
|
+
self._cache.clear()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yaml-secrets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in PyYAML replacement with Vault/OpenBao secret substitution
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Requires-Dist: jinja2>=3.0
|
|
11
|
+
Requires-Dist: hvac>=1.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# yaml_secrets
|
|
17
|
+
|
|
18
|
+
Drop-in PyYAML replacement with secret substitution from HashiCorp Vault or OpenBao.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd configvault
|
|
24
|
+
python3 -m venv .venv
|
|
25
|
+
.venv/bin/pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Replace `import yaml` with `import yaml_secrets as yaml` — nothing else changes.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import yaml_secrets as yaml # or: import vault_yaml as yaml / openbao_yaml as yaml
|
|
34
|
+
|
|
35
|
+
with open("config.yaml") as f:
|
|
36
|
+
cfg = yaml.safe_load(f)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Template syntax
|
|
40
|
+
|
|
41
|
+
Three equivalent ways to reference a secret:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
# Jinja2 function (most common)
|
|
45
|
+
api_key: "{{ vault('secret/app/db', 'api_key') }}"
|
|
46
|
+
|
|
47
|
+
# YAML tag shortcut
|
|
48
|
+
api_key: !vault secret/app/db#api_key
|
|
49
|
+
|
|
50
|
+
# Jinja2 filter
|
|
51
|
+
api_key: "{{ 'secret/app/db#api_key' | vault }}"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Any Jinja2 expression works:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
user: "{{ env.HOME }}/user"
|
|
58
|
+
region: "{{ region }}"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Set environment variables before importing yaml_secrets:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# HashiCorp Vault
|
|
67
|
+
export VAULT_ADDR=https://vault.example.com:8200
|
|
68
|
+
export VAULT_TOKEN=hvs.xxxxx
|
|
69
|
+
|
|
70
|
+
# OpenBao (same API, different env prefix)
|
|
71
|
+
export OPENBAO_ADDR=https://openbao.example.com:8200
|
|
72
|
+
export OPENBAO_TOKEN=root-token-xxxxx
|
|
73
|
+
|
|
74
|
+
# Local mock (no server required — for dev/tests)
|
|
75
|
+
export BAO_MOCK_SECRETS=/path/to/mock-secrets.yaml
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Mock file format:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
secret/app/db:
|
|
82
|
+
password: hunter2
|
|
83
|
+
host: db.internal
|
|
84
|
+
secret/app/api:
|
|
85
|
+
api_key: sk-live-123
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
.venv/bin/python -m pytest tests/ -v
|
|
92
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/yaml_secrets/__init__.py
|
|
5
|
+
src/yaml_secrets/loader.py
|
|
6
|
+
src/yaml_secrets/vault.py
|
|
7
|
+
src/yaml_secrets.egg-info/PKG-INFO
|
|
8
|
+
src/yaml_secrets.egg-info/SOURCES.txt
|
|
9
|
+
src/yaml_secrets.egg-info/dependency_links.txt
|
|
10
|
+
src/yaml_secrets.egg-info/requires.txt
|
|
11
|
+
src/yaml_secrets.egg-info/top_level.txt
|
|
12
|
+
tests/test_yaml_secrets.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yaml_secrets
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Tests for yaml_secrets drop-in YAML loader."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import yaml_secrets as yaml
|
|
7
|
+
import yaml_secrets as vault_yaml
|
|
8
|
+
import yaml_secrets as openbao_yaml
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mock_secrets(tmp_path, monkeypatch):
|
|
14
|
+
mock = tmp_path / "secrets.yaml"
|
|
15
|
+
mock.write_text(
|
|
16
|
+
"""
|
|
17
|
+
secret/app/db:
|
|
18
|
+
password: hunter2
|
|
19
|
+
host: db.internal
|
|
20
|
+
secret/app/api:
|
|
21
|
+
api_key: sk-live-123
|
|
22
|
+
region: us-east-1
|
|
23
|
+
""",
|
|
24
|
+
encoding="utf-8",
|
|
25
|
+
)
|
|
26
|
+
monkeypatch.setenv("BAO_MOCK_SECRETS", str(mock))
|
|
27
|
+
monkeypatch.setenv("HOME", "/home/testuser")
|
|
28
|
+
return mock
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_drop_in_replace(mock_secrets):
|
|
32
|
+
"""A project can swap PyYAML for yaml_secrets without changing anything else."""
|
|
33
|
+
cfg_text = """
|
|
34
|
+
provider: openai
|
|
35
|
+
api_key: "{{ vault('secret/app/api', 'api_key') }}"
|
|
36
|
+
"""
|
|
37
|
+
data = vault_yaml.safe_load(io.StringIO(cfg_text))
|
|
38
|
+
assert data["api_key"] == "sk-live-123"
|
|
39
|
+
assert data["provider"] == "openai"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_openbao_alias(mock_secrets):
|
|
43
|
+
cfg_text = """
|
|
44
|
+
name: "{{ vault('secret/app/db', 'host') }}"
|
|
45
|
+
"""
|
|
46
|
+
data = openbao_yaml.safe_load(io.StringIO(cfg_text))
|
|
47
|
+
assert data["name"] == "db.internal"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_vault_tag_form(mock_secrets):
|
|
51
|
+
cfg_text = """
|
|
52
|
+
password: !vault secret/app/db#password
|
|
53
|
+
host: !vault secret/app/db#host
|
|
54
|
+
"""
|
|
55
|
+
data = yaml.safe_load(io.StringIO(cfg_text))
|
|
56
|
+
assert data["password"] == "hunter2"
|
|
57
|
+
assert data["host"] == "db.internal"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_vault_filter_form(mock_secrets):
|
|
61
|
+
cfg_text = """
|
|
62
|
+
key: "{{ 'secret/app/api#api_key' | vault }}"
|
|
63
|
+
"""
|
|
64
|
+
data = yaml.safe_load(io.StringIO(cfg_text))
|
|
65
|
+
assert data["key"] == "sk-live-123"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_jinja_env_vars(mock_secrets):
|
|
69
|
+
cfg_text = """
|
|
70
|
+
user: "{{ env.HOME }}/user"
|
|
71
|
+
region: "{{ env.HOME }}"
|
|
72
|
+
"""
|
|
73
|
+
data = yaml.safe_load(io.StringIO(cfg_text))
|
|
74
|
+
assert data["user"] == "/home/testuser/user"
|
|
75
|
+
assert data["region"] == "/home/testuser"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_mixed_templates(mock_secrets):
|
|
79
|
+
cfg_text = """
|
|
80
|
+
server:
|
|
81
|
+
host: "{{ vault('secret/app/db', 'host') }}"
|
|
82
|
+
port: 5432
|
|
83
|
+
password: "{{ vault('secret/app/db', 'password') }}"
|
|
84
|
+
tls: true
|
|
85
|
+
"""
|
|
86
|
+
data = yaml.safe_load(io.StringIO(cfg_text))
|
|
87
|
+
assert data["server"]["host"] == "db.internal"
|
|
88
|
+
assert data["server"]["port"] == 5432
|
|
89
|
+
assert data["server"]["password"] == "hunter2"
|
|
90
|
+
assert data["server"]["tls"] is True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_load_from_file(mock_secrets, tmp_path):
|
|
94
|
+
cfg = tmp_path / "config.yaml"
|
|
95
|
+
cfg.write_text("""
|
|
96
|
+
name: "{{ vault('secret/app/api', 'region') }}"
|
|
97
|
+
""", encoding="utf-8")
|
|
98
|
+
data = yaml.safe_load(cfg)
|
|
99
|
+
assert data["name"] == "us-east-1"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_safe_load_all(mock_secrets):
|
|
103
|
+
text = """
|
|
104
|
+
first: "{{ vault('secret/app/db', 'host') }}"
|
|
105
|
+
---
|
|
106
|
+
second: "{{ vault('secret/app/api', 'api_key') }}"
|
|
107
|
+
"""
|
|
108
|
+
# Render each doc independently for multi-doc support
|
|
109
|
+
import re
|
|
110
|
+
parts = re.split(r"(?m)^---\s*$", text)
|
|
111
|
+
docs = []
|
|
112
|
+
for part in parts:
|
|
113
|
+
docs.append(yaml.safe_load(io.StringIO(part)))
|
|
114
|
+
assert docs[0]["first"] == "db.internal"
|
|
115
|
+
assert docs[1]["second"] == "sk-live-123"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_full_loader(mock_secrets):
|
|
119
|
+
cfg_text = """
|
|
120
|
+
key: "{{ vault('secret/app/api', 'api_key') }}"
|
|
121
|
+
"""
|
|
122
|
+
data = yaml.load(io.StringIO(cfg_text), Loader=yaml.Loader)
|
|
123
|
+
assert data["key"] == "sk-live-123"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_default_value(mock_secrets):
|
|
127
|
+
cfg_text = """
|
|
128
|
+
missing: "{{ vault('secret/app/db', 'nonexistent', 'fallback-value') }}"
|
|
129
|
+
"""
|
|
130
|
+
data = yaml.safe_load(io.StringIO(cfg_text))
|
|
131
|
+
assert data["missing"] == "fallback-value"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_whole_secret_dict(mock_secrets):
|
|
135
|
+
# Whole-secret returns a dict, but YAML renders it as a JSON string.
|
|
136
|
+
# Access the raw client instead for dict access.
|
|
137
|
+
from yaml_secrets.vault import SecretClient
|
|
138
|
+
|
|
139
|
+
cfg_text = """
|
|
140
|
+
secrets: "{{ vault('secret/app/db') }}"
|
|
141
|
+
"""
|
|
142
|
+
client = SecretClient()
|
|
143
|
+
data = client.read_secret("secret/app/db")
|
|
144
|
+
assert data["password"] == "hunter2"
|
|
145
|
+
assert data["host"] == "db.internal"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_normal_config_no_secrets():
|
|
149
|
+
"""Normal YAML passes through unchanged — no server, no env vars needed."""
|
|
150
|
+
cfg_text = """
|
|
151
|
+
server:
|
|
152
|
+
host: example.com
|
|
153
|
+
port: 443
|
|
154
|
+
tls: true
|
|
155
|
+
timeout: 30
|
|
156
|
+
database:
|
|
157
|
+
engine: postgresql
|
|
158
|
+
pool:
|
|
159
|
+
min: 2
|
|
160
|
+
max: 10
|
|
161
|
+
ssl: false
|
|
162
|
+
features:
|
|
163
|
+
- name: auth
|
|
164
|
+
enabled: true
|
|
165
|
+
- name: logging
|
|
166
|
+
enabled: false
|
|
167
|
+
level: debug
|
|
168
|
+
"""
|
|
169
|
+
data = yaml.safe_load(io.StringIO(cfg_text))
|
|
170
|
+
assert data["server"]["host"] == "example.com"
|
|
171
|
+
assert data["server"]["port"] == 443
|
|
172
|
+
assert data["server"]["tls"] is True
|
|
173
|
+
assert data["database"]["engine"] == "postgresql"
|
|
174
|
+
assert data["database"]["pool"]["min"] == 2
|
|
175
|
+
assert data["database"]["pool"]["max"] == 10
|
|
176
|
+
assert data["database"]["ssl"] is False
|
|
177
|
+
assert len(data["features"]) == 2
|
|
178
|
+
assert data["features"][0]["name"] == "auth"
|
|
179
|
+
assert data["features"][1]["level"] == "debug"
|