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/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
+ }