mystack-rig 1.0.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.
cli/config.py ADDED
@@ -0,0 +1,264 @@
1
+ """
2
+ Configuration management for the CLI.
3
+
4
+ Credentials live in ~/.rig/config using AWS-style profiles:
5
+
6
+ [default]
7
+ hetzner_cloud_api_token = ...
8
+ hetzner_s3_access_key = ...
9
+ hetzner_s3_secret_key = ...
10
+ s3_endpoint_url = https://fsn1.your-objectstorage.com
11
+
12
+ [profile staging]
13
+ hetzner_cloud_api_token = ...
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import configparser
19
+ import getpass
20
+ import os
21
+ from typing import Callable, Dict, Optional
22
+
23
+
24
+ DEFAULT_CONFIG_PATH = os.path.expanduser("~/.rig/config")
25
+ DEFAULT_PROFILE = "default"
26
+ DEFAULT_S3_ENDPOINT = "https://fsn1.your-objectstorage.com"
27
+
28
+ PROFILE_KEYS = (
29
+ "hetzner_cloud_api_token",
30
+ "hetzner_s3_access_key",
31
+ "hetzner_s3_secret_key",
32
+ "s3_endpoint_url",
33
+ )
34
+
35
+ # Developer identity for ``.rig/vars.{identity}.yml`` (not a cloud profile).
36
+ IDENTITY_KEY = "identity"
37
+
38
+
39
+ def _section_name(profile: str) -> str:
40
+ if profile == DEFAULT_PROFILE:
41
+ return DEFAULT_PROFILE
42
+ if profile.startswith("profile "):
43
+ return profile
44
+ return f"profile {profile}"
45
+
46
+
47
+ def _profile_from_section(section: str) -> str:
48
+ if section == DEFAULT_PROFILE:
49
+ return DEFAULT_PROFILE
50
+ if section.startswith("profile "):
51
+ return section[len("profile "):]
52
+ return section
53
+
54
+
55
+ class RigConfig:
56
+ """AWS-style profile config stored at ~/.rig/config."""
57
+
58
+ def __init__(self, path: Optional[str] = None, profile: Optional[str] = None):
59
+ self.path = os.path.expanduser(path or os.getenv("RIG_CONFIG") or DEFAULT_CONFIG_PATH)
60
+ self.profile = profile or os.getenv("RIG_PROFILE") or DEFAULT_PROFILE
61
+ self._parser = configparser.ConfigParser()
62
+ self.load()
63
+
64
+ def load(self) -> None:
65
+ self._parser = configparser.ConfigParser()
66
+ if os.path.exists(self.path):
67
+ self._parser.read(self.path)
68
+
69
+ def save(self) -> None:
70
+ directory = os.path.dirname(self.path)
71
+ if directory:
72
+ os.makedirs(directory, mode=0o700, exist_ok=True)
73
+ with open(self.path, "w") as handle:
74
+ self._parser.write(handle)
75
+ try:
76
+ os.chmod(self.path, 0o600)
77
+ if directory:
78
+ os.chmod(directory, 0o700)
79
+ except OSError:
80
+ pass
81
+
82
+ def list_profiles(self) -> list[str]:
83
+ profiles = [_profile_from_section(section) for section in self._parser.sections()]
84
+ if DEFAULT_PROFILE not in profiles and self._parser.has_section(DEFAULT_PROFILE):
85
+ profiles.insert(0, DEFAULT_PROFILE)
86
+ return profiles
87
+
88
+ def get_profile(self, profile: Optional[str] = None) -> Dict[str, str]:
89
+ name = profile or self.profile or DEFAULT_PROFILE
90
+ section = _section_name(name)
91
+ if not self._parser.has_section(section):
92
+ # Accept bare section names too, matching ~/.aws/credentials style.
93
+ if name != DEFAULT_PROFILE and self._parser.has_section(name):
94
+ section = name
95
+ else:
96
+ return {}
97
+ return {
98
+ key: value
99
+ for key, value in self._parser.items(section)
100
+ if key in PROFILE_KEYS and value
101
+ }
102
+
103
+ def set_profile(self, values: Dict[str, str], profile: Optional[str] = None) -> None:
104
+ name = profile or self.profile or DEFAULT_PROFILE
105
+ section = _section_name(name)
106
+ if not self._parser.has_section(section):
107
+ self._parser.add_section(section)
108
+ for key, value in values.items():
109
+ if key not in PROFILE_KEYS:
110
+ continue
111
+ if value:
112
+ self._parser.set(section, key, value)
113
+ elif self._parser.has_option(section, key):
114
+ self._parser.remove_option(section, key)
115
+ self.save()
116
+
117
+ def resolve(self, profile: Optional[str] = None) -> Dict[str, str]:
118
+ """Merge environment variables over the selected profile."""
119
+ stored = self.get_profile(profile)
120
+ return {
121
+ "hetzner_cloud_api_token": (
122
+ os.getenv("HETZNER_CLOUD_API_TOKEN")
123
+ or os.getenv("HZ_TOKEN")
124
+ or stored.get("hetzner_cloud_api_token", "")
125
+ ),
126
+ "hetzner_s3_access_key": (
127
+ os.getenv("HETZNER_S3_ACCESS_KEY")
128
+ or os.getenv("HETZNER_CLOUD_OBJECT_STORAGE_ACCESS_KEY")
129
+ or stored.get("hetzner_s3_access_key", "")
130
+ ),
131
+ "hetzner_s3_secret_key": (
132
+ os.getenv("HETZNER_S3_SECRET_KEY")
133
+ or os.getenv("HETZNER_CLOUD_OBJECT_STORAGE_SECRET_KEY")
134
+ or stored.get("hetzner_s3_secret_key", "")
135
+ ),
136
+ "s3_endpoint_url": (
137
+ os.getenv("S3_ENDPOINT_URL")
138
+ or stored.get("s3_endpoint_url")
139
+ or DEFAULT_S3_ENDPOINT
140
+ ),
141
+ }
142
+
143
+ def apply_to_environ(self, profile: Optional[str] = None) -> Dict[str, str]:
144
+ """Export resolved credentials into the process environment."""
145
+ resolved = self.resolve(profile)
146
+ mapping = {
147
+ "hetzner_cloud_api_token": "HETZNER_CLOUD_API_TOKEN",
148
+ "hetzner_s3_access_key": "HETZNER_S3_ACCESS_KEY",
149
+ "hetzner_s3_secret_key": "HETZNER_S3_SECRET_KEY",
150
+ "s3_endpoint_url": "S3_ENDPOINT_URL",
151
+ }
152
+ for key, env_name in mapping.items():
153
+ value = resolved.get(key) or ""
154
+ if value and not os.getenv(env_name):
155
+ os.environ[env_name] = value
156
+ return resolved
157
+
158
+ def get_identity(self) -> Optional[str]:
159
+ """Return persisted developer identity from the active profile section."""
160
+ section = _section_name(self.profile or DEFAULT_PROFILE)
161
+ if not self._parser.has_section(section):
162
+ if self.profile != DEFAULT_PROFILE and self._parser.has_section(self.profile):
163
+ section = self.profile
164
+ elif self._parser.has_section(DEFAULT_PROFILE):
165
+ section = DEFAULT_PROFILE
166
+ else:
167
+ return None
168
+ value = self._parser.get(section, IDENTITY_KEY, fallback="").strip()
169
+ return value or None
170
+
171
+ def set_identity(self, identity: str) -> None:
172
+ """Persist developer identity on the active profile section."""
173
+ from cli.vault_keys import sanitize_identity
174
+
175
+ value = sanitize_identity(identity)
176
+ section = _section_name(self.profile or DEFAULT_PROFILE)
177
+ if not self._parser.has_section(section):
178
+ self._parser.add_section(section)
179
+ self._parser.set(section, IDENTITY_KEY, value)
180
+ self.save()
181
+
182
+
183
+ def _prompt_secret(message: str, default: str = "", input_fn=None, secret_fn=None) -> str:
184
+ ask = secret_fn or getpass.getpass
185
+ plain = input_fn or input
186
+ if default:
187
+ suffix = f" [{default[:4]}…]" if len(default) > 4 else f" [{default}]"
188
+ # Keep existing values visible only as a short prefix; Enter keeps them.
189
+ try:
190
+ answer = ask(f"{message}{suffix}: ")
191
+ except Exception:
192
+ answer = plain(f"{message}{suffix}: ")
193
+ return answer.strip() or default
194
+ try:
195
+ answer = ask(f"{message}: ")
196
+ except Exception:
197
+ answer = plain(f"{message}: ")
198
+ return answer.strip()
199
+
200
+
201
+ def ensure_credentials(
202
+ config: Optional[RigConfig] = None,
203
+ input_fn: Optional[Callable[..., str]] = None,
204
+ secret_fn: Optional[Callable[..., str]] = None,
205
+ force_prompt: bool = False,
206
+ ) -> Dict[str, str]:
207
+ """Return credentials, prompting and saving to ~/.rig/config when needed."""
208
+ config = config or RigConfig()
209
+ resolved = config.resolve()
210
+ missing_token = not resolved.get("hetzner_cloud_api_token")
211
+ missing_s3 = not (
212
+ resolved.get("hetzner_s3_access_key") and resolved.get("hetzner_s3_secret_key")
213
+ )
214
+
215
+ if not force_prompt and not missing_token and not missing_s3:
216
+ config.apply_to_environ()
217
+ return resolved
218
+
219
+ print(f"Hetzner credentials ({config.profile} profile in {config.path})")
220
+ token = resolved.get("hetzner_cloud_api_token", "")
221
+ access_key = resolved.get("hetzner_s3_access_key", "")
222
+ secret_key = resolved.get("hetzner_s3_secret_key", "")
223
+ endpoint = resolved.get("s3_endpoint_url", DEFAULT_S3_ENDPOINT)
224
+
225
+ if force_prompt or missing_token:
226
+ token = _prompt_secret(
227
+ "Hetzner Cloud API token",
228
+ default=token,
229
+ input_fn=input_fn,
230
+ secret_fn=secret_fn,
231
+ )
232
+ if force_prompt or missing_s3:
233
+ access_key = _prompt_secret(
234
+ "Hetzner S3 access key",
235
+ default=access_key,
236
+ input_fn=input_fn,
237
+ secret_fn=secret_fn,
238
+ )
239
+ secret_key = _prompt_secret(
240
+ "Hetzner S3 secret key",
241
+ default=secret_key,
242
+ input_fn=input_fn,
243
+ secret_fn=secret_fn,
244
+ )
245
+ ask = input_fn or input
246
+ endpoint_answer = ask(f"S3 endpoint URL [{endpoint}]: ").strip()
247
+ if endpoint_answer:
248
+ endpoint = endpoint_answer
249
+
250
+ if not token:
251
+ raise ValueError("Hetzner Cloud API token is required")
252
+ if not access_key or not secret_key:
253
+ raise ValueError("Hetzner S3 access and secret keys are required")
254
+
255
+ values = {
256
+ "hetzner_cloud_api_token": token,
257
+ "hetzner_s3_access_key": access_key,
258
+ "hetzner_s3_secret_key": secret_key,
259
+ "s3_endpoint_url": endpoint or DEFAULT_S3_ENDPOINT,
260
+ }
261
+ config.set_profile(values)
262
+ config.apply_to_environ()
263
+ print(f"Saved credentials to {config.path} [{config.profile}]")
264
+ return values