|
4 | 4 | import contextlib |
5 | 5 | import uuid |
6 | 6 | import xml.etree.ElementTree as ET |
| 7 | +from datetime import UTC, datetime |
7 | 8 | from urllib.parse import parse_qs |
8 | 9 |
|
9 | 10 | import structlog |
|
24 | 25 | LIST_HEAD_CONCURRENCY = 50 |
25 | 26 |
|
26 | 27 |
|
| 28 | +def _s3_timestamp(value: object) -> str: |
| 29 | + """Format a listing timestamp as S3 does: RFC3339 in UTC with a 'Z' suffix. |
| 30 | +
|
| 31 | + datetime.isoformat() emits a '+00:00' offset, which strict S3 clients reject |
| 32 | + — scylla-manager's rclone 1.51.0 fails the whole listing with "cannot parse |
| 33 | + '+00:00' as 'Z'". Emit millisecond precision + 'Z' to match real S3. |
| 34 | + """ |
| 35 | + if not isinstance(value, datetime): |
| 36 | + return str(value) |
| 37 | + d = value.astimezone(UTC) |
| 38 | + return f"{d:%Y-%m-%dT%H:%M:%S}.{d.microsecond // 1000:03d}Z" |
| 39 | + |
| 40 | + |
27 | 41 | def _strip_minio_cache_suffix(value: str | None) -> str | None: |
28 | 42 | """Strip MinIO cache metadata suffix from marker/token values. |
29 | 43 |
|
@@ -207,7 +221,7 @@ async def resolve(obj: dict) -> dict: |
207 | 221 | size, etag = obj.get("Size", 0), obj.get("ETag", "").strip('"') |
208 | 222 | return { |
209 | 223 | "key": obj["Key"], |
210 | | - "last_modified": obj["LastModified"].isoformat(), |
| 224 | + "last_modified": _s3_timestamp(obj["LastModified"]), |
211 | 225 | "etag": etag, |
212 | 226 | "size": size, |
213 | 227 | "storage_class": obj.get("StorageClass", "STANDARD"), |
@@ -297,9 +311,7 @@ async def handle_list_multipart_uploads( |
297 | 311 | { |
298 | 312 | "Key": key, |
299 | 313 | "UploadId": upload.get("UploadId", ""), |
300 | | - "Initiated": upload.get("Initiated", "").isoformat() |
301 | | - if hasattr(upload.get("Initiated"), "isoformat") |
302 | | - else str(upload.get("Initiated", "")), |
| 314 | + "Initiated": _s3_timestamp(upload.get("Initiated", "")), |
303 | 315 | "StorageClass": upload.get("StorageClass", "STANDARD"), |
304 | 316 | } |
305 | 317 | ) |
|
0 commit comments