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/__init__.py +6 -0
- cli/__main__.py +8 -0
- cli/agents.py +575 -0
- cli/config.py +264 -0
- cli/encrypt.py +1182 -0
- cli/git.py +317 -0
- cli/last_env.py +64 -0
- cli/last_session.py +171 -0
- cli/log.py +105 -0
- cli/main.py +1234 -0
- cli/remote_dev.py +5547 -0
- cli/requirements.txt +4 -0
- cli/snapshots.py +627 -0
- cli/storage.py +348 -0
- cli/vault_keys.py +451 -0
- cli/vpn.py +323 -0
- mystack_rig-1.0.0.dist-info/METADATA +285 -0
- mystack_rig-1.0.0.dist-info/RECORD +22 -0
- mystack_rig-1.0.0.dist-info/WHEEL +5 -0
- mystack_rig-1.0.0.dist-info/entry_points.txt +2 -0
- mystack_rig-1.0.0.dist-info/licenses/LICENSE +21 -0
- mystack_rig-1.0.0.dist-info/top_level.txt +1 -0
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
|