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/storage.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage management for the CLI using Hetzner S3-compatible object storage
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Dict
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.config import Config
|
|
10
|
+
from botocore.exceptions import ClientError
|
|
11
|
+
|
|
12
|
+
from cli import log
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StorageManager:
|
|
16
|
+
"""Manages storage operations with Hetzner S3-compatible storage"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, hetzner_token: Optional[str] = None, endpoint_url: Optional[str] = None):
|
|
19
|
+
self.hetzner_token = hetzner_token or os.getenv('HZ_TOKEN') or os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
20
|
+
self.endpoint_url = endpoint_url or os.getenv('S3_ENDPOINT_URL') or "https://fsn1.your-objectstorage.com"
|
|
21
|
+
|
|
22
|
+
# Read S3 credentials from environment variables
|
|
23
|
+
# Hetzner uses HETZNER_S3_ACCESS_KEY and HETZNER_S3_SECRET_KEY
|
|
24
|
+
# Also supports HETZNER_CLOUD_OBJECT_STORAGE_ACCESS_KEY and HETZNER_CLOUD_OBJECT_STORAGE_SECRET_KEY
|
|
25
|
+
self.s3_access_key = os.getenv('HETZNER_S3_ACCESS_KEY') or os.getenv('HETZNER_CLOUD_OBJECT_STORAGE_ACCESS_KEY')
|
|
26
|
+
self.s3_secret_key = os.getenv('HETZNER_S3_SECRET_KEY') or os.getenv('HETZNER_CLOUD_OBJECT_STORAGE_SECRET_KEY')
|
|
27
|
+
|
|
28
|
+
if not self.hetzner_token:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"Hetzner Cloud token missing. Please set HZ_TOKEN or HETZNER_CLOUD_API_TOKEN environment variable")
|
|
31
|
+
|
|
32
|
+
if not self.s3_access_key or not self.s3_secret_key:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"S3 credentials missing. Please set HETZNER_S3_ACCESS_KEY and HETZNER_S3_SECRET_KEY environment variables")
|
|
35
|
+
|
|
36
|
+
def _endpoint_region(self) -> str:
|
|
37
|
+
"""Location id embedded in the Hetzner Object Storage endpoint hostname."""
|
|
38
|
+
url = (self.endpoint_url or "").lower()
|
|
39
|
+
for region in ("hel1", "fsn1", "nbg1"):
|
|
40
|
+
if region in url:
|
|
41
|
+
return region
|
|
42
|
+
return "fsn1"
|
|
43
|
+
|
|
44
|
+
def _s3_config(self) -> Config:
|
|
45
|
+
"""
|
|
46
|
+
Hetzner Object Storage requires path-style URLs and SigV4.
|
|
47
|
+
|
|
48
|
+
Virtual-hosted style (``bucket.endpoint``) yields BadRequest / NoSuchBucket
|
|
49
|
+
on PutObject even right after a successful CreateBucket.
|
|
50
|
+
"""
|
|
51
|
+
return Config(
|
|
52
|
+
signature_version="s3v4",
|
|
53
|
+
s3={"addressing_style": "path"},
|
|
54
|
+
# Object storage can be slow under load; avoid flaky short timeouts.
|
|
55
|
+
connect_timeout=10,
|
|
56
|
+
read_timeout=120,
|
|
57
|
+
retries={"max_attempts": 5, "mode": "standard"},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _get_s3_client(self):
|
|
61
|
+
"""Get S3 client configured for Hetzner Object Storage."""
|
|
62
|
+
return boto3.client(
|
|
63
|
+
"s3",
|
|
64
|
+
endpoint_url=self.endpoint_url,
|
|
65
|
+
region_name=self._endpoint_region(),
|
|
66
|
+
aws_access_key_id=self.s3_access_key,
|
|
67
|
+
aws_secret_access_key=self.s3_secret_key,
|
|
68
|
+
config=self._s3_config(),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _is_not_found(exc: BaseException) -> bool:
|
|
73
|
+
"""True for RGW/S3 not-found responses (eventual consistency)."""
|
|
74
|
+
if not isinstance(exc, ClientError):
|
|
75
|
+
# boto3 transfer manager wraps some 404s.
|
|
76
|
+
msg = str(exc)
|
|
77
|
+
return "404" in msg or "NoSuchKey" in msg or "Not Found" in msg
|
|
78
|
+
code = str(exc.response.get("Error", {}).get("Code", "") or "")
|
|
79
|
+
http = int(exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") or 0)
|
|
80
|
+
return code in ("404", "NoSuchBucket", "NoSuchKey", "NotFound") or http == 404
|
|
81
|
+
|
|
82
|
+
def _wait_for_bucket(self, s3_client, bucket_name: str, timeout_s: float = 30.0) -> bool:
|
|
83
|
+
"""Poll until the bucket is visible to HeadBucket (eventual consistency)."""
|
|
84
|
+
deadline = time.time() + timeout_s
|
|
85
|
+
delay = 0.5
|
|
86
|
+
while time.time() < deadline:
|
|
87
|
+
try:
|
|
88
|
+
s3_client.head_bucket(Bucket=bucket_name)
|
|
89
|
+
return True
|
|
90
|
+
except ClientError as exc:
|
|
91
|
+
# 403 can mean the bucket exists but is not yet readable on this gateway.
|
|
92
|
+
http = int(exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") or 0)
|
|
93
|
+
if self._is_not_found(exc) or http == 403:
|
|
94
|
+
time.sleep(delay)
|
|
95
|
+
delay = min(delay * 1.5, 3.0)
|
|
96
|
+
continue
|
|
97
|
+
log.warning("HeadBucket for %r failed: %s", bucket_name, exc)
|
|
98
|
+
return False
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
log.warning("HeadBucket for %r failed: %s", bucket_name, exc)
|
|
101
|
+
return False
|
|
102
|
+
log.warning("Timed out waiting for bucket %r to become ready", bucket_name)
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def _wait_for_object(
|
|
106
|
+
self, s3_client, bucket_name: str, object_name: str, timeout_s: float = 30.0
|
|
107
|
+
) -> bool:
|
|
108
|
+
"""Poll until the object is visible to HeadObject (Hetzner RGW lag)."""
|
|
109
|
+
deadline = time.time() + timeout_s
|
|
110
|
+
delay = 0.2
|
|
111
|
+
while time.time() < deadline:
|
|
112
|
+
try:
|
|
113
|
+
s3_client.head_object(Bucket=bucket_name, Key=object_name)
|
|
114
|
+
return True
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
if self._is_not_found(exc):
|
|
117
|
+
time.sleep(delay)
|
|
118
|
+
delay = min(delay * 1.5, 2.0)
|
|
119
|
+
continue
|
|
120
|
+
log.warning(
|
|
121
|
+
"HeadObject for %r/%r failed: %s", bucket_name, object_name, exc
|
|
122
|
+
)
|
|
123
|
+
return False
|
|
124
|
+
log.warning(
|
|
125
|
+
"Timed out waiting for object %r/%r to become ready",
|
|
126
|
+
bucket_name,
|
|
127
|
+
object_name,
|
|
128
|
+
)
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def create_bucket(self, bucket_name: str, location: Optional[str] = None) -> bool:
|
|
132
|
+
"""Create a new bucket using Hetzner Object Storage"""
|
|
133
|
+
try:
|
|
134
|
+
s3_client = self._get_s3_client()
|
|
135
|
+
try:
|
|
136
|
+
s3_client.create_bucket(Bucket=bucket_name)
|
|
137
|
+
log.debug("Created storage bucket %r", bucket_name)
|
|
138
|
+
except ClientError as create_error:
|
|
139
|
+
error_code = create_error.response.get("Error", {}).get("Code", "")
|
|
140
|
+
# If bucket already exists, that's fine
|
|
141
|
+
if error_code in ("BucketAlreadyOwnedByYou", "BucketAlreadyExists"):
|
|
142
|
+
log.debug("Storage bucket %r already exists", bucket_name)
|
|
143
|
+
elif "LocationConstraintConflict" in str(create_error):
|
|
144
|
+
# Retry once with the same client (path-style + region already set).
|
|
145
|
+
s3_client.create_bucket(Bucket=bucket_name)
|
|
146
|
+
log.debug(
|
|
147
|
+
"Created storage bucket %r (region %s)",
|
|
148
|
+
bucket_name,
|
|
149
|
+
self._endpoint_region(),
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
if not self._wait_for_bucket(s3_client, bucket_name):
|
|
155
|
+
return False
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
except ClientError as e:
|
|
159
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
160
|
+
|
|
161
|
+
# If bucket already exists, that's fine
|
|
162
|
+
if error_code == 'BucketAlreadyOwnedByYou' or error_code == 'BucketAlreadyExists':
|
|
163
|
+
log.debug("Storage bucket %r already exists", bucket_name)
|
|
164
|
+
s3_client = self._get_s3_client()
|
|
165
|
+
return self._wait_for_bucket(s3_client, bucket_name)
|
|
166
|
+
|
|
167
|
+
log.warning("Could not create storage bucket: %s", e)
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def list_buckets(self) -> list:
|
|
171
|
+
"""List all buckets"""
|
|
172
|
+
try:
|
|
173
|
+
s3_client = self._get_s3_client()
|
|
174
|
+
response = s3_client.list_buckets()
|
|
175
|
+
buckets = [bucket['Name'] for bucket in response['Buckets']]
|
|
176
|
+
log.debug("Storage buckets: %s", buckets)
|
|
177
|
+
return buckets
|
|
178
|
+
except ClientError as e:
|
|
179
|
+
log.warning("Could not list storage buckets: %s", e)
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
def upload_file(self, bucket_name: str, file_path: str, object_name: Optional[str] = None) -> bool:
|
|
183
|
+
"""Upload a file to storage"""
|
|
184
|
+
if not os.path.exists(file_path):
|
|
185
|
+
log.warning("Local file not found for upload: %s", file_path)
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
if object_name is None:
|
|
189
|
+
object_name = os.path.basename(file_path)
|
|
190
|
+
|
|
191
|
+
s3_client = self._get_s3_client()
|
|
192
|
+
deadline = time.time() + 30.0
|
|
193
|
+
delay = 0.2
|
|
194
|
+
last_error: Optional[BaseException] = None
|
|
195
|
+
|
|
196
|
+
while time.time() < deadline:
|
|
197
|
+
try:
|
|
198
|
+
s3_client.upload_file(file_path, bucket_name, object_name)
|
|
199
|
+
# Hetzner Object Storage can 404 Head/Get for a short window after Put.
|
|
200
|
+
if not self._wait_for_object(s3_client, bucket_name, object_name):
|
|
201
|
+
last_error = RuntimeError("object not visible after upload")
|
|
202
|
+
time.sleep(delay)
|
|
203
|
+
delay = min(delay * 1.5, 2.0)
|
|
204
|
+
continue
|
|
205
|
+
log.debug("Uploaded %s -> %s/%s", file_path, bucket_name, object_name)
|
|
206
|
+
return True
|
|
207
|
+
except Exception as e:
|
|
208
|
+
last_error = e
|
|
209
|
+
# RGW gateways briefly return NoSuchBucket even for buckets we just used.
|
|
210
|
+
if self._is_not_found(e) or "NoSuchBucket" in str(e):
|
|
211
|
+
time.sleep(delay)
|
|
212
|
+
delay = min(delay * 1.5, 2.0)
|
|
213
|
+
continue
|
|
214
|
+
log.warning("Could not upload file: %s", e)
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
log.warning("Could not upload file after retries: %s", last_error)
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
def download_file(self, bucket_name: str, object_name: str, file_path: str) -> bool:
|
|
221
|
+
"""Download a file from storage"""
|
|
222
|
+
s3_client = self._get_s3_client()
|
|
223
|
+
deadline = time.time() + 30.0
|
|
224
|
+
delay = 0.2
|
|
225
|
+
last_error: Optional[BaseException] = None
|
|
226
|
+
while time.time() < deadline:
|
|
227
|
+
try:
|
|
228
|
+
s3_client.download_file(bucket_name, object_name, file_path)
|
|
229
|
+
log.debug("Downloaded %s/%s -> %s", bucket_name, object_name, file_path)
|
|
230
|
+
return True
|
|
231
|
+
except OSError as e:
|
|
232
|
+
log.debug("Could not download file: %s", e)
|
|
233
|
+
return False
|
|
234
|
+
except Exception as e:
|
|
235
|
+
last_error = e
|
|
236
|
+
if self._is_not_found(e):
|
|
237
|
+
time.sleep(delay)
|
|
238
|
+
delay = min(delay * 1.5, 2.0)
|
|
239
|
+
continue
|
|
240
|
+
log.debug("Could not download file: %s", e)
|
|
241
|
+
return False
|
|
242
|
+
log.debug("Could not download file after retries: %s", last_error)
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
def list_objects(self, bucket_name: str, prefix: str = "") -> list:
|
|
246
|
+
"""List objects in a bucket.
|
|
247
|
+
|
|
248
|
+
Hetzner Object Storage can return NoSuchBucket for a short window
|
|
249
|
+
after a successful upload, so that response is retried.
|
|
250
|
+
"""
|
|
251
|
+
s3_client = self._get_s3_client()
|
|
252
|
+
deadline = time.time() + 30.0
|
|
253
|
+
delay = 0.2
|
|
254
|
+
last_error: Optional[BaseException] = None
|
|
255
|
+
while time.time() < deadline:
|
|
256
|
+
try:
|
|
257
|
+
response = s3_client.list_objects_v2(Bucket=bucket_name, Prefix=prefix)
|
|
258
|
+
objects = []
|
|
259
|
+
if 'Contents' in response:
|
|
260
|
+
objects = [obj['Key'] for obj in response['Contents']]
|
|
261
|
+
log.debug(
|
|
262
|
+
"Storage objects in %r (prefix %r): %s",
|
|
263
|
+
bucket_name,
|
|
264
|
+
prefix,
|
|
265
|
+
objects,
|
|
266
|
+
)
|
|
267
|
+
return objects
|
|
268
|
+
except ClientError as e:
|
|
269
|
+
last_error = e
|
|
270
|
+
if self._is_not_found(e):
|
|
271
|
+
time.sleep(delay)
|
|
272
|
+
delay = min(delay * 1.5, 2.0)
|
|
273
|
+
continue
|
|
274
|
+
log.warning("Could not list storage objects: %s", e)
|
|
275
|
+
return []
|
|
276
|
+
log.warning("Could not list storage objects: %s", last_error)
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
def delete_object(self, bucket_name: str, object_name: str) -> bool:
|
|
280
|
+
"""Delete an object from storage.
|
|
281
|
+
|
|
282
|
+
A missing key is success. NoSuchBucket is retried because the gateway
|
|
283
|
+
can report it while the bucket is still there.
|
|
284
|
+
"""
|
|
285
|
+
s3_client = self._get_s3_client()
|
|
286
|
+
deadline = time.time() + 30.0
|
|
287
|
+
delay = 0.2
|
|
288
|
+
last_error: Optional[BaseException] = None
|
|
289
|
+
while time.time() < deadline:
|
|
290
|
+
try:
|
|
291
|
+
s3_client.delete_object(Bucket=bucket_name, Key=object_name)
|
|
292
|
+
log.debug("Deleted %s/%s", bucket_name, object_name)
|
|
293
|
+
return True
|
|
294
|
+
except ClientError as e:
|
|
295
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
296
|
+
if error_code in ("NoSuchKey", "404", "NotFound"):
|
|
297
|
+
return True
|
|
298
|
+
last_error = e
|
|
299
|
+
if error_code == "NoSuchBucket" or self._is_not_found(e):
|
|
300
|
+
time.sleep(delay)
|
|
301
|
+
delay = min(delay * 1.5, 2.0)
|
|
302
|
+
continue
|
|
303
|
+
log.warning("Could not delete storage object: %s", e)
|
|
304
|
+
return False
|
|
305
|
+
log.warning("Could not delete storage object: %s", last_error)
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
def delete_bucket(self, bucket_name: str) -> bool:
|
|
309
|
+
"""Delete a bucket. A bucket that is already gone counts as deleted."""
|
|
310
|
+
s3_client = self._get_s3_client()
|
|
311
|
+
deadline = time.time() + 30.0
|
|
312
|
+
delay = 0.2
|
|
313
|
+
last_error: Optional[BaseException] = None
|
|
314
|
+
while time.time() < deadline:
|
|
315
|
+
try:
|
|
316
|
+
s3_client.delete_bucket(Bucket=bucket_name)
|
|
317
|
+
log.debug("Deleted storage bucket %r", bucket_name)
|
|
318
|
+
return True
|
|
319
|
+
except ClientError as e:
|
|
320
|
+
last_error = e
|
|
321
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
322
|
+
if error_code in ("BucketNotEmpty", "NoSuchBucket") or self._is_not_found(e):
|
|
323
|
+
try:
|
|
324
|
+
s3_client.head_bucket(Bucket=bucket_name)
|
|
325
|
+
except ClientError as head_exc:
|
|
326
|
+
if self._is_not_found(head_exc):
|
|
327
|
+
log.debug("Storage bucket %r is already gone", bucket_name)
|
|
328
|
+
return True
|
|
329
|
+
time.sleep(delay)
|
|
330
|
+
delay = min(delay * 1.5, 2.0)
|
|
331
|
+
continue
|
|
332
|
+
log.warning("Could not delete storage bucket: %s", e)
|
|
333
|
+
return False
|
|
334
|
+
log.warning("Could not delete storage bucket: %s", last_error)
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
def get_credentials_info(self) -> Dict[str, str]:
|
|
338
|
+
"""Return information about the configured credentials (token masked)."""
|
|
339
|
+
token_part = self.hetzner_token or ""
|
|
340
|
+
if len(token_part) > 4:
|
|
341
|
+
masked = f"{'*' * (len(token_part) - 4)}{token_part[-4:]}"
|
|
342
|
+
else:
|
|
343
|
+
masked = token_part
|
|
344
|
+
return {
|
|
345
|
+
"endpoint": self.endpoint_url or "",
|
|
346
|
+
"token_used": masked,
|
|
347
|
+
"credentials_expiration": "Temporary credentials generated by Hetzner",
|
|
348
|
+
}
|