cnos-hashicorp 1.12.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.
@@ -0,0 +1,33 @@
1
+ .coop/logs/
2
+ .coop/tmp/
3
+ node_modules/
4
+ dist/
5
+ coverage/
6
+ .artifacts/
7
+ .turbo/
8
+ .DS_Store
9
+ Thumbs.db
10
+ .idea/
11
+ .vscode/
12
+ *.log
13
+ .env
14
+ .env.local
15
+ .env.*.local
16
+ !.env.example
17
+ pnpm-debug.log*
18
+
19
+ # Claude Code worktrees and session scratch
20
+ .claude/
21
+ .tmp/
22
+
23
+ # Python bytecode
24
+ __pycache__/
25
+ *.pyc
26
+ *.pyo
27
+ *.pyd
28
+ .pytest_cache/
29
+
30
+ # Java build output
31
+ target/
32
+ *.class
33
+ *.jar
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: cnos-hashicorp
3
+ Version: 1.12.0
4
+ Summary: CNOS HashiCorp Vault vault provider
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: cnos>=1.12.0
7
+ Requires-Dist: hvac>=1.2.0
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cnos-hashicorp"
7
+ version = "1.12.0"
8
+ description = "CNOS HashiCorp Vault vault provider"
9
+ requires-python = ">=3.8"
10
+ dependencies = [
11
+ "cnos>=1.12.0",
12
+ "hvac>=1.2.0",
13
+ ]
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["src/cnos_hashicorp"]
17
+
18
+ [tool.pytest.ini_options]
19
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ """CNOS HashiCorp Vault provider."""
2
+ from cnos_hashicorp.provider import HashiCorpVaultProvider, factory
3
+
4
+ __all__ = ["HashiCorpVaultProvider", "factory"]
@@ -0,0 +1,235 @@
1
+ """HashiCorp Vault CNOS vault provider — mirrors Go's hashicorpvault.go."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ from cnos.errors import CnosError
7
+ from cnos.types import (
8
+ SecretVaultProvider,
9
+ SecretVaultProviderFactory,
10
+ VaultAuthConfig,
11
+ VaultDefinition,
12
+ )
13
+
14
+ PROVIDER_NAME = "hashicorp-vault"
15
+
16
+
17
+ def _string_config(config: Optional[Dict[str, Any]], key: str) -> str:
18
+ if not config:
19
+ return ""
20
+ val = config.get(key, "")
21
+ return str(val).strip() if val else ""
22
+
23
+
24
+ def _first_string_config(config: Optional[Dict[str, Any]], *keys: str) -> str:
25
+ for key in keys:
26
+ val = _string_config(config, key)
27
+ if val:
28
+ return val
29
+ return ""
30
+
31
+
32
+ def _read_version(value: Any) -> int:
33
+ if isinstance(value, int) and value in (1, 2):
34
+ return value
35
+ if isinstance(value, str):
36
+ stripped = value.strip()
37
+ if stripped in ("1", "kv-v1"):
38
+ return 1
39
+ if stripped in ("2", "kv-v2"):
40
+ return 2
41
+ return 0
42
+
43
+
44
+ def _unique_sorted(refs: List[str]) -> List[str]:
45
+ return sorted(set(refs))
46
+
47
+
48
+ def _join_path(*segments: str) -> str:
49
+ parts = []
50
+ for seg in segments:
51
+ trimmed = seg.strip("/")
52
+ if trimmed:
53
+ parts.append(trimmed)
54
+ return "/".join(parts)
55
+
56
+
57
+ class _ParsedRef:
58
+ def __init__(self, path: str, field: str, explicit_field: bool) -> None:
59
+ self.path = path
60
+ self.field = field
61
+ self.explicit_field = explicit_field
62
+
63
+
64
+ def _parse_vault_ref(ref: str) -> _ParsedRef:
65
+ index = ref.rfind("#")
66
+ if index == -1:
67
+ return _ParsedRef(path=ref, field="value", explicit_field=False)
68
+ field = ref[index + 1:] or "value"
69
+ return _ParsedRef(path=ref[:index], field=field, explicit_field=True)
70
+
71
+
72
+ def _read_kv_data(data: Dict[str, Any], version: int) -> Optional[Dict[str, Any]]:
73
+ if version != 2:
74
+ return data
75
+ nested = data.get("data")
76
+ if isinstance(nested, dict):
77
+ return nested
78
+ return None
79
+
80
+
81
+ def _decode_vault_value(
82
+ data: Optional[Dict[str, Any]], field: str, explicit_field: bool
83
+ ) -> Optional[str]:
84
+ if data is None:
85
+ return None
86
+ val = data.get(field)
87
+ if val is not None:
88
+ s = _primitive_string(val)
89
+ if s is not None:
90
+ return s
91
+ if explicit_field:
92
+ return None
93
+ # Try to find a single primitive field
94
+ primitives = []
95
+ for v in data.values():
96
+ s = _primitive_string(v)
97
+ if s is not None:
98
+ primitives.append(s)
99
+ if len(primitives) == 1:
100
+ return primitives[0]
101
+ return None
102
+
103
+
104
+ def _primitive_string(value: Any) -> Optional[str]:
105
+ if isinstance(value, str):
106
+ return value
107
+ if isinstance(value, bool):
108
+ return "true" if value else "false"
109
+ if isinstance(value, int):
110
+ return str(value)
111
+ if isinstance(value, float):
112
+ return str(value)
113
+ return None
114
+
115
+
116
+ class HashiCorpVaultProvider(SecretVaultProvider):
117
+ """HashiCorp Vault KV provider. Supports injecting a mock client for tests."""
118
+
119
+ def __init__(
120
+ self,
121
+ vault_id: str,
122
+ definition: VaultDefinition,
123
+ client: Any = None,
124
+ ) -> None:
125
+ self._vault_id = vault_id
126
+ self._definition = definition
127
+ self._config = self._read_config(definition)
128
+ self._client = client
129
+ self._token: str = ""
130
+ self._authenticated = False
131
+
132
+ @staticmethod
133
+ def _read_config(definition: VaultDefinition) -> Dict[str, Any]:
134
+ config = definition.auth.config or {}
135
+ version = _read_version(config.get("version"))
136
+ if version == 0:
137
+ version = 2
138
+ mount = _string_config(config, "mount") or "secret"
139
+ return {
140
+ "address": _first_string_config(config, "address", "endpoint", "url"),
141
+ "mount": mount,
142
+ "namespace": _string_config(config, "namespace"),
143
+ "version": version,
144
+ "path": _string_config(config, "path"),
145
+ }
146
+
147
+ def authenticate(self, auth: VaultAuthConfig) -> None:
148
+ if auth.method != "token" or not auth.token:
149
+ raise CnosError(
150
+ f'vault "{self._vault_id}" uses {PROVIDER_NAME} and requires token authentication'
151
+ )
152
+ self._token = auth.token
153
+ self._authenticated = True
154
+ if self._client is None:
155
+ self._client = self._build_client()
156
+
157
+ def _build_client(self) -> Any:
158
+ try:
159
+ import hvac # type: ignore[import]
160
+ except ImportError as exc:
161
+ raise CnosError(
162
+ "cnos-hashicorp: hvac is required. Install with: pip install hvac"
163
+ ) from exc
164
+ kwargs: Dict[str, Any] = {}
165
+ if self._config["address"]:
166
+ kwargs["url"] = self._config["address"]
167
+ client = hvac.Client(**kwargs)
168
+ client.token = self._token
169
+ return client
170
+
171
+ def batch_get(self, refs: List[str]) -> Dict[str, Any]:
172
+ result: Dict[str, Any] = {}
173
+ for ref in _unique_sorted(refs):
174
+ val = self._get_one(ref)
175
+ if val is not None:
176
+ result[ref] = val
177
+ return result
178
+
179
+ def get(self, ref: str) -> Optional[Any]:
180
+ return self._get_one(ref)
181
+
182
+ def _get_one(self, ref: str) -> Optional[str]:
183
+ external = self._external_ref(ref)
184
+ parsed = _parse_vault_ref(external)
185
+ read_path = self._read_path(parsed.path)
186
+ data, status = self._client_read(read_path)
187
+ if status == 404 or data is None:
188
+ return None
189
+ kv_data = _read_kv_data(data, self._config["version"])
190
+ return _decode_vault_value(kv_data, parsed.field, parsed.explicit_field)
191
+
192
+ def _client_read(self, path: str) -> Tuple[Optional[Dict[str, Any]], int]:
193
+ """Returns (data, status_code). 404 on not-found."""
194
+ try:
195
+ data, status = self._client.read(
196
+ path,
197
+ self._token,
198
+ self._config["namespace"],
199
+ )
200
+ return data, status
201
+ except Exception as exc:
202
+ if self._is_not_found(exc):
203
+ return None, 404
204
+ raise CnosError(f"cnos-hashicorp: read {path!r} failed: {exc}") from exc
205
+
206
+ def _read_path(self, path: str) -> str:
207
+ if self._config["version"] == 2:
208
+ return _join_path(self._config["mount"], "data", self._config["path"], path)
209
+ return _join_path(self._config["mount"], self._config["path"], path)
210
+
211
+ def _external_ref(self, ref: str) -> str:
212
+ mapping = self._definition.mapping or {}
213
+ for external, logical in mapping.items():
214
+ if logical == ref:
215
+ return external
216
+ return ref
217
+
218
+ @staticmethod
219
+ def _is_not_found(exc: Exception) -> bool:
220
+ cls_name = type(exc).__name__
221
+ if "InvalidPath" in cls_name or "NotFound" in cls_name:
222
+ return True
223
+ status = getattr(exc, "status_code", None)
224
+ return status == 404
225
+
226
+
227
+ def factory(client: Any = None) -> SecretVaultProviderFactory:
228
+ """Return a SecretVaultProviderFactory for HashiCorp Vault.
229
+
230
+ Pass client= to inject a mock for tests.
231
+ """
232
+ def create(vault_id: str, definition: VaultDefinition) -> HashiCorpVaultProvider:
233
+ return HashiCorpVaultProvider(vault_id, definition, client=client)
234
+
235
+ return SecretVaultProviderFactory(provider=PROVIDER_NAME, create=create)
File without changes
@@ -0,0 +1,214 @@
1
+ """Tests for the HashiCorp Vault provider."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, Optional, Tuple
5
+
6
+ import pytest
7
+
8
+ from cnos.types import VaultAuthConfig, VaultAuthDefinition, VaultDefinition
9
+ from cnos_hashicorp.provider import (
10
+ HashiCorpVaultProvider,
11
+ PROVIDER_NAME,
12
+ _parse_vault_ref,
13
+ _decode_vault_value,
14
+ _join_path,
15
+ )
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Mock client — matches the interface expected by provider._client_read
20
+ # ---------------------------------------------------------------------------
21
+
22
+ class MockHvacClient:
23
+ def __init__(self, kv_data: dict, not_found: set = None, version: int = 2):
24
+ self._kv_data = kv_data # path -> dict of fields
25
+ self._not_found = not_found or set()
26
+ self._version = version
27
+ self.token = ""
28
+
29
+ def read(self, path: str, token: str, namespace: str) -> Tuple[Optional[Dict], int]:
30
+ for key in self._not_found:
31
+ if key in path:
32
+ return None, 404
33
+ for key, val in self._kv_data.items():
34
+ if key in path:
35
+ if self._version == 2:
36
+ return {"data": val}, 200
37
+ return val, 200
38
+ return None, 404
39
+
40
+
41
+ def _make_provider(
42
+ kv_data: dict,
43
+ not_found: set = None,
44
+ mapping: dict = None,
45
+ version: int = 2,
46
+ kv_path: str = "",
47
+ ) -> HashiCorpVaultProvider:
48
+ config: Dict[str, Any] = {"version": version}
49
+ if kv_path:
50
+ config["path"] = kv_path
51
+ definition = VaultDefinition(
52
+ provider=PROVIDER_NAME,
53
+ auth=VaultAuthDefinition(method="token", config=config),
54
+ mapping=mapping or {},
55
+ )
56
+ client = MockHvacClient(kv_data, not_found, version)
57
+ p = HashiCorpVaultProvider("test-vault", definition, client=client)
58
+ p._token = "test-token"
59
+ p._authenticated = True
60
+ return p
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Tests
65
+ # ---------------------------------------------------------------------------
66
+
67
+ class TestAuthenticate:
68
+ def test_token_accepted(self):
69
+ p = _make_provider({})
70
+ p._authenticated = False
71
+ p.authenticate(VaultAuthConfig(method="token", token="mytoken"))
72
+ assert p._authenticated
73
+
74
+ def test_wrong_method_raises(self):
75
+ p = _make_provider({})
76
+ p._authenticated = False
77
+ with pytest.raises(Exception, match="token authentication"):
78
+ p.authenticate(VaultAuthConfig(method="iam"))
79
+
80
+ def test_empty_token_raises(self):
81
+ p = _make_provider({})
82
+ p._authenticated = False
83
+ with pytest.raises(Exception, match="token authentication"):
84
+ p.authenticate(VaultAuthConfig(method="token", token=""))
85
+
86
+
87
+ class TestParseVaultRef:
88
+ def test_no_field_defaults_to_value(self):
89
+ parsed = _parse_vault_ref("my/secret")
90
+ assert parsed.path == "my/secret"
91
+ assert parsed.field == "value"
92
+ assert parsed.explicit_field is False
93
+
94
+ def test_explicit_field(self):
95
+ parsed = _parse_vault_ref("my/secret#password")
96
+ assert parsed.path == "my/secret"
97
+ assert parsed.field == "password"
98
+ assert parsed.explicit_field is True
99
+
100
+ def test_empty_field_defaults_to_value(self):
101
+ parsed = _parse_vault_ref("my/secret#")
102
+ assert parsed.field == "value"
103
+
104
+ def test_uses_last_hash(self):
105
+ parsed = _parse_vault_ref("my#path/secret#field")
106
+ assert parsed.field == "field"
107
+ assert parsed.path == "my#path/secret"
108
+
109
+
110
+ class TestDecodeVaultValue:
111
+ def test_returns_named_field(self):
112
+ data = {"value": "secret", "other": "x"}
113
+ result = _decode_vault_value(data, "value", False)
114
+ assert result == "secret"
115
+
116
+ def test_explicit_field(self):
117
+ data = {"password": "hunter2"}
118
+ result = _decode_vault_value(data, "password", True)
119
+ assert result == "hunter2"
120
+
121
+ def test_single_primitive_fallback(self):
122
+ data = {"token": "abc123"}
123
+ result = _decode_vault_value(data, "value", False)
124
+ assert result == "abc123"
125
+
126
+ def test_multiple_fields_no_explicit_returns_none(self):
127
+ data = {"a": "1", "b": "2"}
128
+ result = _decode_vault_value(data, "value", False)
129
+ assert result is None
130
+
131
+ def test_none_data(self):
132
+ result = _decode_vault_value(None, "value", False)
133
+ assert result is None
134
+
135
+
136
+ class TestBatchGet:
137
+ def test_returns_secret(self):
138
+ p = _make_provider({"my-secret": {"value": "secret123"}})
139
+ result = p.batch_get(["my-secret"])
140
+ assert result == {"my-secret": "secret123"}
141
+
142
+ def test_not_found_excluded(self):
143
+ p = _make_provider({}, not_found={"missing"})
144
+ result = p.batch_get(["missing"])
145
+ assert "missing" not in result
146
+
147
+ def test_multiple_secrets(self):
148
+ p = _make_provider({"s1": {"value": "v1"}, "s2": {"value": "v2"}})
149
+ result = p.batch_get(["s1", "s2"])
150
+ assert result["s1"] == "v1"
151
+ assert result["s2"] == "v2"
152
+
153
+ def test_deduplicates(self):
154
+ p = _make_provider({"s1": {"value": "v1"}})
155
+ result = p.batch_get(["s1", "s1"])
156
+ assert result == {"s1": "v1"}
157
+
158
+
159
+ class TestGet:
160
+ def test_existing(self):
161
+ p = _make_provider({"sec": {"value": "val"}})
162
+ assert p.get("sec") == "val"
163
+
164
+ def test_missing_returns_none(self):
165
+ p = _make_provider({}, not_found={"missing"})
166
+ assert p.get("missing") is None
167
+
168
+
169
+ class TestKvVersion:
170
+ def test_v2_wraps_data(self):
171
+ p = _make_provider({"sec": {"value": "v2-secret"}}, version=2)
172
+ result = p.batch_get(["sec"])
173
+ assert result.get("sec") == "v2-secret"
174
+
175
+ def test_v1_no_wrapping(self):
176
+ p = _make_provider({"sec": {"value": "v1-secret"}}, version=1)
177
+ result = p.batch_get(["sec"])
178
+ assert result.get("sec") == "v1-secret"
179
+
180
+ def test_read_path_v2(self):
181
+ p = _make_provider({}, version=2)
182
+ path = p._read_path("my/secret")
183
+ assert "data" in path
184
+
185
+ def test_read_path_v1(self):
186
+ p = _make_provider({}, version=1)
187
+ path = p._read_path("my/secret")
188
+ assert "data" not in path
189
+
190
+
191
+ class TestMapping:
192
+ def test_external_mapping(self):
193
+ p = _make_provider(
194
+ {"external-path": {"value": "mapped!"}},
195
+ mapping={"external-path": "logical-name"},
196
+ )
197
+ result = p.batch_get(["logical-name"])
198
+ assert result.get("logical-name") == "mapped!"
199
+
200
+
201
+ class TestJoinPath:
202
+ def test_basic(self):
203
+ assert _join_path("a", "b", "c") == "a/b/c"
204
+
205
+ def test_strips_slashes(self):
206
+ assert _join_path("/a/", "/b/") == "a/b"
207
+
208
+ def test_empty_segments_skipped(self):
209
+ assert _join_path("a", "", "b") == "a/b"
210
+
211
+
212
+ class TestProviderName:
213
+ def test_constant(self):
214
+ assert PROVIDER_NAME == "hashicorp-vault"