oto-core 1.6.0__py3-none-any.whl
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.
- oto/config.py +291 -0
- oto/scaleway_secrets.py +131 -0
- oto/sops_secrets.py +149 -0
- oto/tools/__init__.py +1 -0
- oto/tools/anthropic/__init__.py +5 -0
- oto/tools/anthropic/client.py +354 -0
- oto/tools/anthropic_batch/__init__.py +5 -0
- oto/tools/anthropic_batch/client.py +352 -0
- oto/tools/apollo/__init__.py +5 -0
- oto/tools/apollo/client.py +191 -0
- oto/tools/attio/__init__.py +5 -0
- oto/tools/attio/client.py +616 -0
- oto/tools/audio/__init__.py +5 -0
- oto/tools/audio/client.py +116 -0
- oto/tools/boamp/__init__.py +3 -0
- oto/tools/boamp/client.py +69 -0
- oto/tools/bodacc/__init__.py +3 -0
- oto/tools/bodacc/client.py +4 -0
- oto/tools/browser/__init__.py +39 -0
- oto/tools/browser/crunchbase.py +423 -0
- oto/tools/browser/g2.py +236 -0
- oto/tools/browser/google.py +104 -0
- oto/tools/browser/indeed.py +282 -0
- oto/tools/browser/linkedin/__init__.py +5 -0
- oto/tools/browser/linkedin/_js.py +255 -0
- oto/tools/browser/linkedin/client.py +277 -0
- oto/tools/browser/linkedin/outreach.py +264 -0
- oto/tools/browser/linkedin/scrape.py +228 -0
- oto/tools/browser/linkedin/search.py +273 -0
- oto/tools/browser/pappers.py +344 -0
- oto/tools/browser/sncf.py +112 -0
- oto/tools/clearbit/__init__.py +5 -0
- oto/tools/clearbit/client.py +126 -0
- oto/tools/collective/__init__.py +5 -0
- oto/tools/collective/client.py +333 -0
- oto/tools/common/__init__.py +5 -0
- oto/tools/common/rate_limiter.py +475 -0
- oto/tools/culture/__init__.py +10 -0
- oto/tools/culture/opendatasoft.py +117 -0
- oto/tools/culture/spectacle.py +174 -0
- oto/tools/datastore/__init__.py +4 -0
- oto/tools/datastore/client.py +99 -0
- oto/tools/dvf/__init__.py +3 -0
- oto/tools/dvf/client.py +4 -0
- oto/tools/figma/__init__.py +5 -0
- oto/tools/figma/client.py +254 -0
- oto/tools/folk/__init__.py +5 -0
- oto/tools/folk/client.py +203 -0
- oto/tools/fullenrich/__init__.py +0 -0
- oto/tools/fullenrich/client.py +184 -0
- oto/tools/gemini/__init__.py +5 -0
- oto/tools/gemini/client.py +264 -0
- oto/tools/gocardless/__init__.py +5 -0
- oto/tools/gocardless/client.py +270 -0
- oto/tools/google/__init__.py +1 -0
- oto/tools/google/calendar/__init__.py +0 -0
- oto/tools/google/calendar/commands.py +100 -0
- oto/tools/google/calendar/lib/__init__.py +0 -0
- oto/tools/google/calendar/lib/calendar_client.py +195 -0
- oto/tools/google/credentials.py +177 -0
- oto/tools/google/docs/commands.py +91 -0
- oto/tools/google/docs/lib/__init__.py +0 -0
- oto/tools/google/docs/lib/docs_client.py +525 -0
- oto/tools/google/docs/lib/markdown_to_docs.py +274 -0
- oto/tools/google/docs/lib/markdown_to_html.py +98 -0
- oto/tools/google/docs/sync/__init__.py +1 -0
- oto/tools/google/drive/check_quota.py +31 -0
- oto/tools/google/drive/commands.py +140 -0
- oto/tools/google/drive/lib/drive_client.py +546 -0
- oto/tools/google/drive/list_shared_drives.py +32 -0
- oto/tools/google/gmail/__init__.py +1 -0
- oto/tools/google/gmail/commands.py +223 -0
- oto/tools/google/gmail/lib/__init__.py +1 -0
- oto/tools/google/gmail/lib/gmail_client.py +482 -0
- oto/tools/google/keep/__init__.py +0 -0
- oto/tools/google/keep/lib/__init__.py +0 -0
- oto/tools/google/keep/lib/keep_client.py +273 -0
- oto/tools/google/sheets/commands.py +85 -0
- oto/tools/google/sheets/lib/__init__.py +0 -0
- oto/tools/google/sheets/lib/sheets_client.py +158 -0
- oto/tools/google/slides/commands.py +136 -0
- oto/tools/google/slides/lib/__init__.py +1 -0
- oto/tools/google/slides/lib/slides_client.py +1516 -0
- oto/tools/google/tasks/__init__.py +0 -0
- oto/tools/google/tasks/commands.py +137 -0
- oto/tools/google/tasks/lib/__init__.py +0 -0
- oto/tools/google/tasks/lib/tasks_client.py +162 -0
- oto/tools/groq/__init__.py +5 -0
- oto/tools/groq/client.py +165 -0
- oto/tools/hithorizons/__init__.py +5 -0
- oto/tools/hithorizons/client.py +168 -0
- oto/tools/hunter/__init__.py +5 -0
- oto/tools/hunter/client.py +104 -0
- oto/tools/inpi/__init__.py +3 -0
- oto/tools/inpi/client.py +4 -0
- oto/tools/kaspr/__init__.py +5 -0
- oto/tools/kaspr/client.py +97 -0
- oto/tools/lemlist/__init__.py +5 -0
- oto/tools/lemlist/client.py +486 -0
- oto/tools/markdown_lint.py +62 -0
- oto/tools/mistral/__init__.py +5 -0
- oto/tools/mistral/client.py +149 -0
- oto/tools/naf/__init__.py +5 -0
- oto/tools/naf/suggester.py +140 -0
- oto/tools/ninja/__init__.py +4 -0
- oto/tools/ninja/client.py +99 -0
- oto/tools/notion/lib/markdown_converter.py +287 -0
- oto/tools/notion/lib/notion_client.py +315 -0
- oto/tools/openai/__init__.py +5 -0
- oto/tools/openai/client.py +167 -0
- oto/tools/pdf/__init__.py +89 -0
- oto/tools/pdf/templates/default.css +114 -0
- oto/tools/pennylane/__init__.py +5 -0
- oto/tools/pennylane/client.py +458 -0
- oto/tools/phantombuster/__init__.py +5 -0
- oto/tools/phantombuster/client.py +177 -0
- oto/tools/reddit/__init__.py +5 -0
- oto/tools/reddit/client.py +216 -0
- oto/tools/resend/__init__.py +5 -0
- oto/tools/resend/client.py +149 -0
- oto/tools/serpapi/__init__.py +5 -0
- oto/tools/serpapi/client.py +128 -0
- oto/tools/serper/__init__.py +5 -0
- oto/tools/serper/client.py +222 -0
- oto/tools/sirene/__init__.py +34 -0
- oto/tools/sirene/client.py +19 -0
- oto/tools/sirene/data/naf_codes.txt +732 -0
- oto/tools/sirene/entreprises.py +4 -0
- oto/tools/sirene/stock.py +184 -0
- oto/tools/slack/__init__.py +5 -0
- oto/tools/slack/client.py +383 -0
- oto/tools/supabase/__init__.py +4 -0
- oto/tools/supabase/client.py +98 -0
- oto/tools/unsplash/__init__.py +5 -0
- oto/tools/unsplash/client.py +197 -0
- oto/tools/whatsapp/__init__.py +3 -0
- oto/tools/whatsapp/client.py +120 -0
- oto/tools/whatsapp/node/package-lock.json +1564 -0
- oto/tools/whatsapp/node/package.json +11 -0
- oto/tools/whatsapp/node/whatsapp.mjs +382 -0
- oto/tools/wttj/__init__.py +5 -0
- oto/tools/wttj/client.py +251 -0
- oto/tools/zerobounce/__init__.py +5 -0
- oto/tools/zerobounce/client.py +93 -0
- oto/tools/zoho/__init__.py +5 -0
- oto/tools/zoho/client.py +184 -0
- oto/tools/zohodesk/__init__.py +5 -0
- oto/tools/zohodesk/client.py +190 -0
- oto_core-1.6.0.dist-info/METADATA +67 -0
- oto_core-1.6.0.dist-info/RECORD +153 -0
- oto_core-1.6.0.dist-info/WHEEL +5 -0
- oto_core-1.6.0.dist-info/licenses/LICENSE +21 -0
- oto_core-1.6.0.dist-info/top_level.txt +1 -0
oto/config.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Configuration loader for otomata tools.
|
|
2
|
+
|
|
3
|
+
Secret resolution order:
|
|
4
|
+
1. Environment variable (always)
|
|
5
|
+
2. Configured provider (sops, file, or scaleway)
|
|
6
|
+
3. Default value
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Dict, Any
|
|
13
|
+
|
|
14
|
+
# Cache for parsed secrets files
|
|
15
|
+
_secrets_cache: Dict[Path, Dict[str, str]] = {}
|
|
16
|
+
_oto_config_cache: Optional[Dict[str, Any]] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AmbiguousSecretError(RuntimeError):
|
|
20
|
+
"""The key exists in the vault but with different values across files.
|
|
21
|
+
|
|
22
|
+
Raised by get_secret: returning one of the values would be arbitrary
|
|
23
|
+
(the key is scoped per project/mission file, not transverse)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_env_file(path: Path) -> Dict[str, str]:
|
|
27
|
+
"""Parse a .env file into a dictionary."""
|
|
28
|
+
if path in _secrets_cache:
|
|
29
|
+
return _secrets_cache[path]
|
|
30
|
+
|
|
31
|
+
result = {}
|
|
32
|
+
if path.exists():
|
|
33
|
+
with open(path, "r") as f:
|
|
34
|
+
for line in f:
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if not line or line.startswith("#"):
|
|
37
|
+
continue
|
|
38
|
+
if "=" in line:
|
|
39
|
+
key, value = line.split("=", 1)
|
|
40
|
+
value = value.strip()
|
|
41
|
+
# Remove quotes if present
|
|
42
|
+
if (value.startswith("'") and value.endswith("'")) or (
|
|
43
|
+
value.startswith('"') and value.endswith('"')
|
|
44
|
+
):
|
|
45
|
+
value = value[1:-1]
|
|
46
|
+
result[key.strip()] = value
|
|
47
|
+
|
|
48
|
+
_secrets_cache[path] = result
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _find_project_secrets() -> Optional[Path]:
|
|
53
|
+
"""Find .otomata/secrets.env in CWD or parent directories."""
|
|
54
|
+
cwd = Path.cwd()
|
|
55
|
+
|
|
56
|
+
# Check CWD and up to 4 parent levels
|
|
57
|
+
for _ in range(5):
|
|
58
|
+
secrets_file = cwd / ".otomata" / "secrets.env"
|
|
59
|
+
if secrets_file.exists():
|
|
60
|
+
return secrets_file
|
|
61
|
+
if cwd.parent == cwd:
|
|
62
|
+
break
|
|
63
|
+
cwd = cwd.parent
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_user_secrets() -> Path:
|
|
69
|
+
"""Get user secrets file path (~/.otomata/secrets.env)."""
|
|
70
|
+
return Path.home() / ".otomata" / "secrets.env"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_oto_config() -> Dict[str, Any]:
|
|
74
|
+
"""Read ~/.otomata/config.yaml. Cached."""
|
|
75
|
+
global _oto_config_cache
|
|
76
|
+
if _oto_config_cache is not None:
|
|
77
|
+
return _oto_config_cache
|
|
78
|
+
|
|
79
|
+
config_file = Path.home() / ".otomata" / "config.yaml"
|
|
80
|
+
if config_file.exists():
|
|
81
|
+
import yaml
|
|
82
|
+
with open(config_file) as f:
|
|
83
|
+
_oto_config_cache = yaml.safe_load(f) or {}
|
|
84
|
+
else:
|
|
85
|
+
_oto_config_cache = {}
|
|
86
|
+
return _oto_config_cache
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_provider() -> str:
|
|
90
|
+
"""Return configured secret provider ('sops', 'file', or 'scaleway').
|
|
91
|
+
|
|
92
|
+
`sops` is the new default — secrets decrypted on demand from a SOPS
|
|
93
|
+
YAML file (see `oto.sops_secrets`). `file` and `scaleway` are kept for
|
|
94
|
+
backwards compat and migration.
|
|
95
|
+
"""
|
|
96
|
+
return _get_oto_config().get("secret_provider", "sops")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def write_oto_config(config: Dict[str, Any]) -> None:
|
|
100
|
+
"""Write ~/.otomata/config.yaml."""
|
|
101
|
+
global _oto_config_cache
|
|
102
|
+
import yaml
|
|
103
|
+
config_file = get_config_dir() / "config.yaml"
|
|
104
|
+
with open(config_file, "w") as f:
|
|
105
|
+
yaml.dump(config, f, default_flow_style=False)
|
|
106
|
+
_oto_config_cache = config
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_search_provider() -> str:
|
|
110
|
+
"""Return configured search provider ('serper' or 'browser')."""
|
|
111
|
+
return _get_oto_config().get("search_provider", "serper")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_MISSING = object()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _file_provider_lookup(name: str):
|
|
118
|
+
"""Look `name` up in the file provider (project secrets, then user secrets).
|
|
119
|
+
|
|
120
|
+
Returns the value, or the `_MISSING` sentinel when not present (so an empty
|
|
121
|
+
string stored on purpose is distinguishable from "not found").
|
|
122
|
+
"""
|
|
123
|
+
project_secrets = _find_project_secrets()
|
|
124
|
+
if project_secrets:
|
|
125
|
+
secrets = _parse_env_file(project_secrets)
|
|
126
|
+
if name in secrets:
|
|
127
|
+
return secrets[name]
|
|
128
|
+
user_secrets = _get_user_secrets()
|
|
129
|
+
secrets = _parse_env_file(user_secrets)
|
|
130
|
+
if name in secrets:
|
|
131
|
+
return secrets[name]
|
|
132
|
+
return _MISSING
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
|
136
|
+
"""
|
|
137
|
+
Get a secret value.
|
|
138
|
+
|
|
139
|
+
Resolution order:
|
|
140
|
+
1. Environment variable (always, highest priority)
|
|
141
|
+
2. Configured provider (sops / scaleway / file)
|
|
142
|
+
3. Local file provider as a graceful fallback when the configured provider
|
|
143
|
+
has no backing store (e.g. fresh/third-party install with sops default
|
|
144
|
+
but no SOPS repo cloned)
|
|
145
|
+
4. Default value
|
|
146
|
+
|
|
147
|
+
`get_secret` is the soft accessor: it honours its contract and returns
|
|
148
|
+
`default` when a secret can't be resolved — it never raises just because a
|
|
149
|
+
provider's store is absent. The hard failure (with guidance) belongs to
|
|
150
|
+
`require_secret`.
|
|
151
|
+
|
|
152
|
+
Exception: a key present in the vault with DIFFERENT values across files
|
|
153
|
+
raises AmbiguousSecretError — returning one of them would be arbitrary.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
name: Secret name (e.g., 'GROQ_API_KEY', 'SIRENE_API_KEY')
|
|
157
|
+
default: Default value if not found
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Secret value or default
|
|
161
|
+
"""
|
|
162
|
+
# 1. Environment variable (always)
|
|
163
|
+
env_val = os.environ.get(name)
|
|
164
|
+
if env_val:
|
|
165
|
+
return env_val
|
|
166
|
+
|
|
167
|
+
# Server hardening (oto-mcp#12) : OTO_CONFIG_DISABLE_SOPS=1 ⇒ le process ne
|
|
168
|
+
# résout QUE son environnement — ni SOPS, ni ~/.otomata/secrets.env. Les
|
|
169
|
+
# serveurs (oto-mcp) tirent leurs credentials de leur propre store (DB,
|
|
170
|
+
# injection) ; une lecture filesystem silencieuse ici contournerait ça.
|
|
171
|
+
if os.environ.get("OTO_CONFIG_DISABLE_SOPS") == "1":
|
|
172
|
+
return default
|
|
173
|
+
|
|
174
|
+
# 2. Configured provider. `store_missing` marks the case where the provider
|
|
175
|
+
# is configured but has no backing store, so we can fall back to the
|
|
176
|
+
# local file provider instead of failing.
|
|
177
|
+
provider = get_provider()
|
|
178
|
+
store_missing = False
|
|
179
|
+
if provider == "sops":
|
|
180
|
+
from oto.sops_secrets import fetch_secrets as _sops_fetch, ambiguous_keys
|
|
181
|
+
cfg = _get_oto_config()
|
|
182
|
+
try:
|
|
183
|
+
secrets = _sops_fetch(
|
|
184
|
+
path=cfg.get("sops_file"),
|
|
185
|
+
dir_path=cfg.get("sops_dir"),
|
|
186
|
+
)
|
|
187
|
+
if name in secrets:
|
|
188
|
+
return secrets[name]
|
|
189
|
+
if name in ambiguous_keys():
|
|
190
|
+
files = ambiguous_keys()[name]
|
|
191
|
+
raise AmbiguousSecretError(
|
|
192
|
+
f"Secret '{name}' is defined with DIFFERENT values in several "
|
|
193
|
+
f"vault files: {', '.join(files)}. It is scoped per file, not "
|
|
194
|
+
f"transverse — read the relevant file directly "
|
|
195
|
+
f"(`sops -d <file>`) or pass it via the environment."
|
|
196
|
+
)
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
store_missing = True
|
|
199
|
+
elif provider == "scaleway":
|
|
200
|
+
from oto.scaleway_secrets import fetch_secrets
|
|
201
|
+
secrets = fetch_secrets()
|
|
202
|
+
if name in secrets:
|
|
203
|
+
return secrets[name]
|
|
204
|
+
else:
|
|
205
|
+
# File provider is the primary lookup.
|
|
206
|
+
value = _file_provider_lookup(name)
|
|
207
|
+
if value is not _MISSING:
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
# 3. Graceful fallback to local file secrets when the configured provider's
|
|
211
|
+
# store is absent (sops default but no SOPS repo on a third-party box).
|
|
212
|
+
if store_missing:
|
|
213
|
+
value = _file_provider_lookup(name)
|
|
214
|
+
if value is not _MISSING:
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
return default
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_json_secret(name: str) -> Optional[Dict[str, Any]]:
|
|
221
|
+
"""
|
|
222
|
+
Get a secret that contains JSON data.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
name: Secret name
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Parsed JSON as dictionary, or None if not found
|
|
229
|
+
"""
|
|
230
|
+
value = get_secret(name)
|
|
231
|
+
if value:
|
|
232
|
+
try:
|
|
233
|
+
return json.loads(value)
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
return None
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def require_secret(name: str) -> str:
|
|
240
|
+
"""
|
|
241
|
+
Get a required secret, raise error if not found.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
name: Secret name
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Secret value
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
ValueError: If secret not found
|
|
251
|
+
"""
|
|
252
|
+
value = get_secret(name)
|
|
253
|
+
if value is None:
|
|
254
|
+
if os.environ.get("OTO_CONFIG_DISABLE_SOPS") == "1":
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"Required secret '{name}' not found — filesystem secret stores are "
|
|
257
|
+
f"disabled (OTO_CONFIG_DISABLE_SOPS=1, server mode). Provide it via "
|
|
258
|
+
f"the process environment or the service's credential store (DB)."
|
|
259
|
+
)
|
|
260
|
+
provider = get_provider()
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"Required secret '{name}' not found. Set it via:\n"
|
|
263
|
+
f" - Environment variable: export {name}='...' (always wins, simplest)\n"
|
|
264
|
+
f" - Local file provider: `oto config provider secrets file`, then add\n"
|
|
265
|
+
f" {name}=... to ~/.otomata/secrets.env\n"
|
|
266
|
+
f" - SOPS provider (otomata infra): keep `secret_provider: sops` and add the\n"
|
|
267
|
+
f" key to your SOPS store\n"
|
|
268
|
+
f" (current provider: {provider})"
|
|
269
|
+
)
|
|
270
|
+
return value
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_config_dir() -> Path:
|
|
274
|
+
"""Get otomata config directory (~/.otomata/)."""
|
|
275
|
+
config_dir = Path.home() / ".otomata"
|
|
276
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
return config_dir
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def get_cache_dir() -> Path:
|
|
281
|
+
"""Get otomata cache directory (~/.cache/otomata/)."""
|
|
282
|
+
cache_dir = Path.home() / ".cache" / "otomata"
|
|
283
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
return cache_dir
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_sessions_dir() -> Path:
|
|
288
|
+
"""Get browser sessions directory (~/.otomata/sessions/)."""
|
|
289
|
+
sessions_dir = get_config_dir() / "sessions"
|
|
290
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
return sessions_dir
|
oto/scaleway_secrets.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Scaleway Secret Manager client for oto secrets.
|
|
2
|
+
|
|
3
|
+
Stores all secrets as a single JSON payload in one Scaleway secret ('otomata-secrets').
|
|
4
|
+
Auth via ~/.config/scw/config.yaml (same as scw CLI).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
_SCW_CONFIG = Path.home() / ".config" / "scw" / "config.yaml"
|
|
15
|
+
_BASE_URL = "https://api.scaleway.com/secret-manager/v1beta1/regions/{region}/secrets"
|
|
16
|
+
_SECRET_NAME = "otomata-secrets"
|
|
17
|
+
|
|
18
|
+
# Module-level cache — one API call per process
|
|
19
|
+
_cache: Optional[Dict[str, str]] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_scw_credentials() -> dict:
|
|
23
|
+
"""Load Scaleway credentials from ~/.config/scw/config.yaml."""
|
|
24
|
+
if not _SCW_CONFIG.exists():
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"Scaleway config not found at {_SCW_CONFIG}. "
|
|
27
|
+
"Run 'scw init' to configure."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Parse YAML manually (top-level key: value lines) to avoid pyyaml dependency here
|
|
31
|
+
# Full YAML parsing is in config.py where pyyaml is available
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
with open(_SCW_CONFIG) as f:
|
|
35
|
+
data = yaml.safe_load(f)
|
|
36
|
+
|
|
37
|
+
required = ("access_key", "secret_key", "default_project_id")
|
|
38
|
+
for key in required:
|
|
39
|
+
if key not in data:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Missing '{key}' in {_SCW_CONFIG}. Run 'scw init'."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
"secret_key": data["secret_key"],
|
|
46
|
+
"project_id": data["default_project_id"],
|
|
47
|
+
"region": data.get("default_region", "fr-par"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _headers(creds: dict) -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"X-Auth-Token": creds["secret_key"],
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _base_url(creds: dict) -> str:
|
|
59
|
+
return _BASE_URL.format(region=creds["region"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _find_secret_id(creds: dict) -> Optional[str]:
|
|
63
|
+
"""Find the otomata-secrets secret ID, or None."""
|
|
64
|
+
resp = requests.get(
|
|
65
|
+
_base_url(creds),
|
|
66
|
+
headers=_headers(creds),
|
|
67
|
+
params={"name": _SECRET_NAME, "project_id": creds["project_id"]},
|
|
68
|
+
timeout=10,
|
|
69
|
+
)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
secrets = resp.json().get("secrets", [])
|
|
72
|
+
return secrets[0]["id"] if secrets else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_secrets() -> Dict[str, str]:
|
|
76
|
+
"""Fetch all secrets from Scaleway Secret Manager. Cached per process."""
|
|
77
|
+
global _cache
|
|
78
|
+
if _cache is not None:
|
|
79
|
+
return _cache
|
|
80
|
+
|
|
81
|
+
creds = _load_scw_credentials()
|
|
82
|
+
secret_id = _find_secret_id(creds)
|
|
83
|
+
if not secret_id:
|
|
84
|
+
_cache = {}
|
|
85
|
+
return _cache
|
|
86
|
+
|
|
87
|
+
resp = requests.get(
|
|
88
|
+
f"{_base_url(creds)}/{secret_id}/versions/latest/access",
|
|
89
|
+
headers=_headers(creds),
|
|
90
|
+
timeout=10,
|
|
91
|
+
)
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
|
|
94
|
+
raw = base64.b64decode(resp.json()["data"])
|
|
95
|
+
_cache = json.loads(raw)
|
|
96
|
+
return _cache
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def push_secrets(secrets: Dict[str, str]) -> str:
|
|
100
|
+
"""Push secrets dict to Scaleway SM. Creates secret if needed. Returns version ID."""
|
|
101
|
+
creds = _load_scw_credentials()
|
|
102
|
+
secret_id = _find_secret_id(creds)
|
|
103
|
+
|
|
104
|
+
if not secret_id:
|
|
105
|
+
resp = requests.post(
|
|
106
|
+
_base_url(creds),
|
|
107
|
+
headers=_headers(creds),
|
|
108
|
+
json={
|
|
109
|
+
"name": _SECRET_NAME,
|
|
110
|
+
"project_id": creds["project_id"],
|
|
111
|
+
"type": "opaque",
|
|
112
|
+
},
|
|
113
|
+
timeout=10,
|
|
114
|
+
)
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
secret_id = resp.json()["id"]
|
|
117
|
+
|
|
118
|
+
payload = base64.b64encode(json.dumps(secrets).encode()).decode()
|
|
119
|
+
resp = requests.post(
|
|
120
|
+
f"{_base_url(creds)}/{secret_id}/versions",
|
|
121
|
+
headers=_headers(creds),
|
|
122
|
+
json={"data": payload, "disable_previous": True},
|
|
123
|
+
timeout=10,
|
|
124
|
+
)
|
|
125
|
+
resp.raise_for_status()
|
|
126
|
+
|
|
127
|
+
# Invalidate cache
|
|
128
|
+
global _cache
|
|
129
|
+
_cache = None
|
|
130
|
+
|
|
131
|
+
return resp.json()["revision"]
|
oto/sops_secrets.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""SOPS provider for oto secret resolution.
|
|
2
|
+
|
|
3
|
+
Lit un ou plusieurs fichiers YAML chiffrés via SOPS + age. Configurable via
|
|
4
|
+
`~/.otomata/config.yaml` :
|
|
5
|
+
|
|
6
|
+
- `sops_dir` : répertoire racine (préféré). Tous les `*.yaml` y sont décryptés
|
|
7
|
+
récursivement et mergés dans un seul dict plat. Défaut : `~/.otomata/secrets/`
|
|
8
|
+
(avec auto-détection si la struct contient un sous-dir avec `.sops.yaml`).
|
|
9
|
+
- `sops_file` : un seul fichier (legacy / mono-fichier). Si défini, prioritaire
|
|
10
|
+
sur `sops_dir` et seul ce fichier est lu.
|
|
11
|
+
|
|
12
|
+
Layout multi-fichiers (cf. AlexisLaporte/secrets) :
|
|
13
|
+
secrets/
|
|
14
|
+
├── secrets.yaml # transverse Otomata
|
|
15
|
+
├── tuls.yaml # host
|
|
16
|
+
├── legacy.yaml # orphelins préservés
|
|
17
|
+
├── missions/*.yaml # par mission/client
|
|
18
|
+
└── projects/*.yaml # projets internes
|
|
19
|
+
|
|
20
|
+
Décrypt délégué au CLI `sops` (déjà installé), résultat parsé en YAML plat,
|
|
21
|
+
caché module-level (une seule décryption par process).
|
|
22
|
+
|
|
23
|
+
Collisions de clés : même valeur ⇒ merge silencieux ; valeurs différentes
|
|
24
|
+
(clé générique scopée par fichier, ex. DATABASE_URL par projet) ⇒ la clé est
|
|
25
|
+
EXCLUE du namespace flat et listée dans `ambiguous_keys()` — `get_secret`
|
|
26
|
+
lève une erreur explicite si on la demande.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import subprocess
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Dict, Optional, List
|
|
33
|
+
|
|
34
|
+
_cache: Optional[Dict[str, str]] = None
|
|
35
|
+
# Clés définies avec des VALEURS DIFFÉRENTES dans plusieurs fichiers (clés
|
|
36
|
+
# génériques scopées par fichier : DATABASE_URL, DB_USER…). Elles sont exclues
|
|
37
|
+
# du namespace flat — les résoudre = lire le fichier scope directement.
|
|
38
|
+
_ambiguous: Dict[str, List[str]] = {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _candidate_default_dirs() -> List[Path]:
|
|
42
|
+
"""Default candidates for the secrets root directory."""
|
|
43
|
+
home = Path.home()
|
|
44
|
+
return [
|
|
45
|
+
home / ".otomata" / "secrets",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _autodetect_dir() -> Optional[Path]:
|
|
50
|
+
"""Find the first candidate dir that contains a .sops.yaml."""
|
|
51
|
+
for c in _candidate_default_dirs():
|
|
52
|
+
if (c / ".sops.yaml").is_file():
|
|
53
|
+
return c
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _decrypt_file(path: Path) -> Dict[str, str]:
|
|
58
|
+
try:
|
|
59
|
+
decrypted = subprocess.run(
|
|
60
|
+
["sops", "--decrypt", str(path)],
|
|
61
|
+
capture_output=True, text=True, check=True,
|
|
62
|
+
).stdout
|
|
63
|
+
except FileNotFoundError as e:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
"sops CLI not found. Install it: "
|
|
66
|
+
"https://github.com/getsops/sops/releases"
|
|
67
|
+
) from e
|
|
68
|
+
except subprocess.CalledProcessError as e:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"sops --decrypt failed for {path}. "
|
|
71
|
+
f"Make sure ~/.config/sops/age/keys.txt matches a recipient in .sops.yaml.\n"
|
|
72
|
+
f"stderr: {e.stderr}"
|
|
73
|
+
) from e
|
|
74
|
+
|
|
75
|
+
import yaml
|
|
76
|
+
data = yaml.safe_load(decrypted) or {}
|
|
77
|
+
return {str(k): str(v) for k, v in data.items()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _merge(
|
|
81
|
+
target: Dict[str, str],
|
|
82
|
+
origins: Dict[str, str],
|
|
83
|
+
source: Dict[str, str],
|
|
84
|
+
source_path: Path,
|
|
85
|
+
) -> None:
|
|
86
|
+
for k, v in source.items():
|
|
87
|
+
if k in _ambiguous:
|
|
88
|
+
_ambiguous[k].append(str(source_path))
|
|
89
|
+
continue
|
|
90
|
+
if k in target and target[k] != v:
|
|
91
|
+
# Valeurs divergentes ⇒ clé scopée par fichier, pas transverse.
|
|
92
|
+
# On la retire du flat ; get_secret lèvera si on la demande.
|
|
93
|
+
_ambiguous[k] = [origins.pop(k), str(source_path)]
|
|
94
|
+
del target[k]
|
|
95
|
+
continue
|
|
96
|
+
target[k] = v
|
|
97
|
+
origins[k] = str(source_path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ambiguous_keys() -> Dict[str, List[str]]:
|
|
101
|
+
"""Clés exclues du namespace flat (valeurs divergentes), avec leurs fichiers."""
|
|
102
|
+
return _ambiguous
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fetch_secrets(
|
|
106
|
+
path: Optional[str] = None,
|
|
107
|
+
dir_path: Optional[str] = None,
|
|
108
|
+
) -> Dict[str, str]:
|
|
109
|
+
"""Decrypt all SOPS files and return a flat dict.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
path: Legacy single-file mode. If set, only this file is read.
|
|
113
|
+
dir_path: Root directory. All `*.yaml` files (recursive) are decrypted
|
|
114
|
+
and merged. If None and `path` is None, autodetects.
|
|
115
|
+
"""
|
|
116
|
+
global _cache
|
|
117
|
+
if _cache is not None:
|
|
118
|
+
return _cache
|
|
119
|
+
|
|
120
|
+
merged: Dict[str, str] = {}
|
|
121
|
+
|
|
122
|
+
if path:
|
|
123
|
+
single = Path(path).expanduser()
|
|
124
|
+
if not single.exists():
|
|
125
|
+
raise FileNotFoundError(f"SOPS secrets file not found at {single}.")
|
|
126
|
+
merged = _decrypt_file(single)
|
|
127
|
+
else:
|
|
128
|
+
root: Optional[Path] = Path(dir_path).expanduser() if dir_path else _autodetect_dir()
|
|
129
|
+
if root is None or not root.is_dir():
|
|
130
|
+
raise FileNotFoundError(
|
|
131
|
+
f"SOPS secrets dir not found. Tried: {[str(c) for c in _candidate_default_dirs()]}. "
|
|
132
|
+
f"Clone: git clone git@github.com:AlexisLaporte/secrets ~/.otomata/secrets"
|
|
133
|
+
)
|
|
134
|
+
yaml_files = sorted(p for p in root.rglob("*.yaml") if p.name != ".sops.yaml")
|
|
135
|
+
if not yaml_files:
|
|
136
|
+
raise FileNotFoundError(f"No .yaml files found under {root}.")
|
|
137
|
+
origins: Dict[str, str] = {}
|
|
138
|
+
for f in yaml_files:
|
|
139
|
+
_merge(merged, origins, _decrypt_file(f), f)
|
|
140
|
+
|
|
141
|
+
_cache = merged
|
|
142
|
+
return _cache
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def invalidate_cache() -> None:
|
|
146
|
+
"""Force a re-decrypt on next fetch (utile après `sops edit`)."""
|
|
147
|
+
global _cache
|
|
148
|
+
_cache = None
|
|
149
|
+
_ambiguous.clear()
|
oto/tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Otomata tools."""
|