charisma-cli 0.5.1__tar.gz → 0.5.2__tar.gz
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.
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/PKG-INFO +1 -1
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/pyproject.toml +1 -1
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/__init__.py +1 -1
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/uploader.py +263 -17
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_uploader.py +428 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/.gitignore +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/README.md +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/config.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/main.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/models.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/parser.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/read_client.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/read_models.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/__init__.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/conftest.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_bamboo_labels.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_cli.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_config.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_integration.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_models.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_open_launch_payload.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_parser.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_read_client.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_read_client_attachments.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_read_client_integration.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_read_client_launches.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_read_client_pagination.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_read_client_results.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_read_client_single_result.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_retry.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.5.1 → charisma_cli-0.5.2}/tests/test_watcher.py +0 -0
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
# is generated from pyproject.toml [project].version, so keeping this literal
|
|
5
5
|
# equal to pyproject guarantees `--version` reports the published version. The
|
|
6
6
|
# prepare-commit step owns the PATCH bump — do not hand-bump here.
|
|
7
|
-
__version__ = "0.5.
|
|
7
|
+
__version__ = "0.5.2"
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
5
7
|
import threading
|
|
6
8
|
import time
|
|
9
|
+
from collections.abc import Callable
|
|
7
10
|
from pathlib import Path
|
|
8
11
|
from queue import Empty, PriorityQueue
|
|
9
12
|
from typing import Any
|
|
@@ -40,6 +43,45 @@ _CONTAINER_ASSOCIATION_TIMEOUT = 60.0
|
|
|
40
43
|
# attachment: enough for an at-a-glance cause without approaching the field cap.
|
|
41
44
|
_SPILL_INLINE_HEAD_BYTES = 2 * 1024
|
|
42
45
|
|
|
46
|
+
# Attachment-transport routing threshold (NOT a truncation threshold). An
|
|
47
|
+
# attachment whose content is at or below this size is sent via the existing
|
|
48
|
+
# API-Gateway multipart route (cheap single request). Anything larger is routed
|
|
49
|
+
# to the presigned-S3 path (presign -> PUT direct to S3 -> complete), so the
|
|
50
|
+
# byte transport bypasses API Gateway's hard ~10MB REST payload ceiling
|
|
51
|
+
# (effectively ~7.5MB after base64 + multipart envelope) and is bounded only by
|
|
52
|
+
# S3 / the backend's single-attachment domain policy. 5MB is a conservative
|
|
53
|
+
# floor well under that ceiling, leaving slack for base64 inflation (4/3) and
|
|
54
|
+
# the multipart envelope. Env-overridable (CHARISMA_ATTACHMENT_APIGW_SAFE_BYTES)
|
|
55
|
+
# for tuning without a code change; a non-positive / unparseable value falls
|
|
56
|
+
# back to the 5MB default.
|
|
57
|
+
_DEFAULT_ATTACHMENT_APIGW_SAFE_BYTES = 5 * 1024 * 1024
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_apigw_safe_bytes() -> int:
|
|
61
|
+
"""Resolve the API-GW attachment routing threshold from env, with a safe default.
|
|
62
|
+
|
|
63
|
+
Reads ``CHARISMA_ATTACHMENT_APIGW_SAFE_BYTES`` (bytes). A missing, empty,
|
|
64
|
+
unparseable, or non-positive value falls back to the 5MB default so the
|
|
65
|
+
threshold is always a sane positive size.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The routing threshold in bytes.
|
|
69
|
+
"""
|
|
70
|
+
raw = os.getenv("CHARISMA_ATTACHMENT_APIGW_SAFE_BYTES", "").strip()
|
|
71
|
+
if not raw:
|
|
72
|
+
return _DEFAULT_ATTACHMENT_APIGW_SAFE_BYTES
|
|
73
|
+
try:
|
|
74
|
+
value = int(raw)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return _DEFAULT_ATTACHMENT_APIGW_SAFE_BYTES
|
|
77
|
+
return value if value > 0 else _DEFAULT_ATTACHMENT_APIGW_SAFE_BYTES
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# text/plain is the content type for spilled error_message / stack_trace fields
|
|
81
|
+
# (safe_attachment_filename always yields a ``*.txt`` name). Fixed once so the
|
|
82
|
+
# presign-signed Content-Type and the PUT Content-Type match byte-for-byte.
|
|
83
|
+
_SPILL_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
84
|
+
|
|
43
85
|
# Sentinel expectedTests for CLI launches: the total count is unknown at open
|
|
44
86
|
# time (directory-watch streaming), so we send the backend maximum. This keeps
|
|
45
87
|
# the launch in 'receiving' across all appends; close_launch() finalizes it.
|
|
@@ -112,6 +154,10 @@ class Uploader:
|
|
|
112
154
|
# avoiding the 404 an early upload would hit (attachments are validated
|
|
113
155
|
# against a stored test_results.id).
|
|
114
156
|
self._pending_spills: list[tuple[str, bytes, str]] = []
|
|
157
|
+
# Attachment-transport routing threshold (bytes). Content at or below
|
|
158
|
+
# this goes via the API-GW multipart route; larger content goes via the
|
|
159
|
+
# presigned-S3 route. Resolved once (env-overridable) at construction.
|
|
160
|
+
self._attachment_apigw_safe_bytes = _resolve_apigw_safe_bytes()
|
|
115
161
|
|
|
116
162
|
@property
|
|
117
163
|
def summary(self) -> UploadSummary:
|
|
@@ -740,31 +786,65 @@ class Uploader:
|
|
|
740
786
|
return
|
|
741
787
|
|
|
742
788
|
# The no-open-launch guard lives in _post_attachment (single owner).
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
789
|
+
# Coerce the content type to a concrete MIME string ONCE here (never
|
|
790
|
+
# None): guess_type returns None for an unrecognized extension, and None
|
|
791
|
+
# cannot be signed/sent as a Content-Type on the presigned-S3 path (it
|
|
792
|
+
# would diverge from the signature). Same fallback the backend's own
|
|
793
|
+
# handle_upload_attachment uses.
|
|
794
|
+
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
795
|
+
self._post_attachment(path.name, content, result_id, content_type)
|
|
796
|
+
|
|
797
|
+
def _post_attachment(
|
|
798
|
+
self, filename: str, content: bytes, result_id: str, content_type: str
|
|
799
|
+
) -> None:
|
|
800
|
+
"""POST attachment bytes to Charisma, routing by size.
|
|
801
|
+
|
|
802
|
+
Shared entry point for both file-backed attachments (``_upload_attachment``)
|
|
803
|
+
and in-memory field spills (``_flush_pending_spills``). Content at or below
|
|
804
|
+
the API-GW routing threshold is sent via the multipart API-GW route
|
|
805
|
+
(``_post_attachment_apigw`` — unchanged behavior for today's normal case).
|
|
806
|
+
Larger content is routed to the presigned-S3 path
|
|
807
|
+
(``_post_attachment_via_s3``) so its byte transport bypasses the API
|
|
808
|
+
Gateway payload ceiling and is bounded only by S3 / the backend's
|
|
809
|
+
single-attachment domain policy.
|
|
810
|
+
|
|
811
|
+
``result_id`` is the backend-assigned ``test_results.id`` from the
|
|
812
|
+
append-results response (ADR-031), matched exactly by the backend.
|
|
813
|
+
``content_type`` is the already-coerced concrete MIME string (never None),
|
|
814
|
+
fixed by the caller so the presign-signed and PUT Content-Type match.
|
|
756
815
|
|
|
757
816
|
Args:
|
|
758
|
-
filename: Attachment filename
|
|
817
|
+
filename: Attachment filename.
|
|
759
818
|
content: Raw attachment bytes.
|
|
760
|
-
result_id: Backend-assigned ``test_results.id`` for the owning result
|
|
761
|
-
|
|
819
|
+
result_id: Backend-assigned ``test_results.id`` for the owning result.
|
|
820
|
+
content_type: Concrete MIME type (never None).
|
|
762
821
|
"""
|
|
763
822
|
if self._client is None or not self._launch_id:
|
|
764
823
|
self._summary.attachments_skipped += 1
|
|
765
824
|
logger.warning("Cannot upload attachment %s: no open launch", filename)
|
|
766
825
|
return
|
|
767
826
|
|
|
827
|
+
if len(content) > self._attachment_apigw_safe_bytes:
|
|
828
|
+
self._post_attachment_via_s3(filename, content, result_id, content_type)
|
|
829
|
+
else:
|
|
830
|
+
self._post_attachment_apigw(filename, content, result_id)
|
|
831
|
+
|
|
832
|
+
def _post_attachment_apigw(self, filename: str, content: bytes, result_id: str) -> None:
|
|
833
|
+
"""POST attachment bytes via the API-Gateway multipart route, with retry.
|
|
834
|
+
|
|
835
|
+
The original (and still default, for content at or below the routing
|
|
836
|
+
threshold) attachment transport: a single multipart request to
|
|
837
|
+
``/api/v1/launches/{launchId}/attachments``. Transient failures (5xx,
|
|
838
|
+
429, connect/timeout) are retried with exponential backoff; terminal
|
|
839
|
+
failures count the attachment skipped and log at WARNING (a silent
|
|
840
|
+
DEBUG-only skip is what hid the original attachments-skipped bug).
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
filename: Attachment filename sent in the multipart body.
|
|
844
|
+
content: Raw attachment bytes.
|
|
845
|
+
result_id: Backend-assigned ``test_results.id`` for the owning result
|
|
846
|
+
(from the append-results response), sent as ``resultId``.
|
|
847
|
+
"""
|
|
768
848
|
files = {"file": (filename, content)}
|
|
769
849
|
data = {"name": filename, "resultId": result_id}
|
|
770
850
|
|
|
@@ -807,6 +887,172 @@ class Uploader:
|
|
|
807
887
|
logger.warning("Attachment upload unexpected error for %s", filename, exc_info=True)
|
|
808
888
|
return
|
|
809
889
|
|
|
890
|
+
def _request_with_retry(
|
|
891
|
+
self,
|
|
892
|
+
describe: str,
|
|
893
|
+
filename: str,
|
|
894
|
+
send: Callable[[], httpx.Response],
|
|
895
|
+
) -> httpx.Response | None:
|
|
896
|
+
"""Run one attachment-step request with the shared retry policy.
|
|
897
|
+
|
|
898
|
+
Centralizes the retry/backoff/terminal-skip contract used by every step
|
|
899
|
+
of the presigned-S3 path (presign, PUT, complete) so the three steps
|
|
900
|
+
cannot drift from each other or from the API-GW route. ``send`` performs
|
|
901
|
+
a single HTTP attempt and returns its ``httpx.Response``. Transient
|
|
902
|
+
failures (status in ``RETRYABLE_STATUS_CODES``, or ``ConnectError`` /
|
|
903
|
+
``TimeoutException``) are retried with exponential backoff; on a terminal
|
|
904
|
+
outcome the attachment is counted skipped, a WARNING is logged, and
|
|
905
|
+
``None`` is returned so the caller aborts the remaining steps.
|
|
906
|
+
|
|
907
|
+
Args:
|
|
908
|
+
describe: Short step label for the WARNING message (e.g. "presign").
|
|
909
|
+
filename: Attachment filename, for the WARNING message.
|
|
910
|
+
send: Zero-arg callable performing one HTTP attempt.
|
|
911
|
+
|
|
912
|
+
Returns:
|
|
913
|
+
The successful ``httpx.Response`` (status not retryable and not an
|
|
914
|
+
error the caller rejects), or ``None`` when the step terminally
|
|
915
|
+
failed (already counted skipped + logged).
|
|
916
|
+
"""
|
|
917
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
918
|
+
try:
|
|
919
|
+
response = send()
|
|
920
|
+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
921
|
+
time.sleep(exponential_backoff(attempt))
|
|
922
|
+
continue
|
|
923
|
+
return response
|
|
924
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
925
|
+
if attempt < MAX_RETRIES:
|
|
926
|
+
time.sleep(exponential_backoff(attempt))
|
|
927
|
+
continue
|
|
928
|
+
self._summary.attachments_skipped += 1
|
|
929
|
+
logger.warning(
|
|
930
|
+
"Attachment %s step failed after %d retries: %s for %s",
|
|
931
|
+
describe,
|
|
932
|
+
MAX_RETRIES,
|
|
933
|
+
e,
|
|
934
|
+
filename,
|
|
935
|
+
)
|
|
936
|
+
return None
|
|
937
|
+
return None
|
|
938
|
+
|
|
939
|
+
def _post_attachment_via_s3(
|
|
940
|
+
self, filename: str, content: bytes, result_id: str, content_type: str
|
|
941
|
+
) -> None:
|
|
942
|
+
"""Upload a large attachment via presign -> direct S3 PUT -> complete.
|
|
943
|
+
|
|
944
|
+
Used for content larger than the API-GW routing threshold. The bytes go
|
|
945
|
+
straight to S3 over a presigned PUT, bypassing API Gateway's payload
|
|
946
|
+
ceiling; only two small JSON requests (presign, complete) traverse the
|
|
947
|
+
API. This is the path that makes the attachment size bounded by S3 /
|
|
948
|
+
the backend single-attachment policy rather than by API Gateway.
|
|
949
|
+
|
|
950
|
+
Contract matched to ``_post_attachment_apigw``: a fully successful
|
|
951
|
+
round-trip increments ``attachments_sent``; any terminal step failure
|
|
952
|
+
increments ``attachments_skipped`` (once) with a WARNING. Each step uses
|
|
953
|
+
the shared retry/backoff policy.
|
|
954
|
+
|
|
955
|
+
Two hard requirements on the S3 PUT (see design):
|
|
956
|
+
- The PUT ``Content-Type`` header equals ``content_type`` byte-for-byte
|
|
957
|
+
(the string the backend signed into the URL), else S3 returns 403
|
|
958
|
+
SignatureDoesNotMatch.
|
|
959
|
+
- The PUT goes through a SEPARATE httpx client with NO ``Authorization``
|
|
960
|
+
header. ``self._client`` carries a client-level Bearer that httpx
|
|
961
|
+
merges into every request (a per-request override does not remove it),
|
|
962
|
+
which must never be sent to S3 — the presigned URL is self-authorizing.
|
|
963
|
+
|
|
964
|
+
Args:
|
|
965
|
+
filename: Attachment filename.
|
|
966
|
+
content: Raw attachment bytes.
|
|
967
|
+
result_id: Backend-assigned ``test_results.id`` for the owning result.
|
|
968
|
+
content_type: Concrete MIME type, signed into the presigned URL and
|
|
969
|
+
echoed verbatim on the PUT and complete steps.
|
|
970
|
+
"""
|
|
971
|
+
presign_body = {
|
|
972
|
+
"resultId": result_id,
|
|
973
|
+
"name": filename,
|
|
974
|
+
"contentType": content_type,
|
|
975
|
+
"size": len(content),
|
|
976
|
+
}
|
|
977
|
+
presign_resp = self._request_with_retry(
|
|
978
|
+
"presign",
|
|
979
|
+
filename,
|
|
980
|
+
lambda: self._client.post(
|
|
981
|
+
f"/api/v1/launches/{self._launch_id}/attachments/presign",
|
|
982
|
+
json=presign_body,
|
|
983
|
+
),
|
|
984
|
+
)
|
|
985
|
+
if presign_resp is None:
|
|
986
|
+
return
|
|
987
|
+
if presign_resp.status_code != 201:
|
|
988
|
+
self._summary.attachments_skipped += 1
|
|
989
|
+
logger.warning(
|
|
990
|
+
"Attachment presign failed: HTTP %d for %s",
|
|
991
|
+
presign_resp.status_code,
|
|
992
|
+
filename,
|
|
993
|
+
)
|
|
994
|
+
return
|
|
995
|
+
|
|
996
|
+
try:
|
|
997
|
+
presign_data = presign_resp.json()
|
|
998
|
+
upload_url = presign_data["uploadUrl"]
|
|
999
|
+
attachment_id = presign_data["attachmentId"]
|
|
1000
|
+
s3_key = presign_data["s3Key"]
|
|
1001
|
+
except (ValueError, json.JSONDecodeError, KeyError, TypeError):
|
|
1002
|
+
self._summary.attachments_skipped += 1
|
|
1003
|
+
logger.warning("Attachment presign returned a malformed body for %s", filename)
|
|
1004
|
+
return
|
|
1005
|
+
|
|
1006
|
+
# Direct S3 PUT: separate client, NO Authorization, Content-Type must
|
|
1007
|
+
# match the signed value exactly.
|
|
1008
|
+
put_resp = self._request_with_retry(
|
|
1009
|
+
"S3 PUT",
|
|
1010
|
+
filename,
|
|
1011
|
+
lambda: httpx.put(
|
|
1012
|
+
upload_url,
|
|
1013
|
+
content=content,
|
|
1014
|
+
headers={"Content-Type": content_type},
|
|
1015
|
+
timeout=60.0,
|
|
1016
|
+
),
|
|
1017
|
+
)
|
|
1018
|
+
if put_resp is None:
|
|
1019
|
+
return
|
|
1020
|
+
if put_resp.status_code not in (200, 201):
|
|
1021
|
+
self._summary.attachments_skipped += 1
|
|
1022
|
+
logger.warning(
|
|
1023
|
+
"Attachment S3 PUT failed: HTTP %d for %s",
|
|
1024
|
+
put_resp.status_code,
|
|
1025
|
+
filename,
|
|
1026
|
+
)
|
|
1027
|
+
return
|
|
1028
|
+
|
|
1029
|
+
complete_body = {
|
|
1030
|
+
"attachmentId": attachment_id,
|
|
1031
|
+
"resultId": result_id,
|
|
1032
|
+
"name": filename,
|
|
1033
|
+
"contentType": content_type,
|
|
1034
|
+
"s3Key": s3_key,
|
|
1035
|
+
}
|
|
1036
|
+
complete_resp = self._request_with_retry(
|
|
1037
|
+
"complete",
|
|
1038
|
+
filename,
|
|
1039
|
+
lambda: self._client.post(
|
|
1040
|
+
f"/api/v1/launches/{self._launch_id}/attachments/complete",
|
|
1041
|
+
json=complete_body,
|
|
1042
|
+
),
|
|
1043
|
+
)
|
|
1044
|
+
if complete_resp is None:
|
|
1045
|
+
return
|
|
1046
|
+
if complete_resp.status_code == 201:
|
|
1047
|
+
self._summary.attachments_sent += 1
|
|
1048
|
+
return
|
|
1049
|
+
self._summary.attachments_skipped += 1
|
|
1050
|
+
logger.warning(
|
|
1051
|
+
"Attachment complete failed: HTTP %d for %s",
|
|
1052
|
+
complete_resp.status_code,
|
|
1053
|
+
filename,
|
|
1054
|
+
)
|
|
1055
|
+
|
|
810
1056
|
def _flush_pending_spills(self) -> None:
|
|
811
1057
|
"""Upload buffered oversized-field spills after results are persisted.
|
|
812
1058
|
|
|
@@ -830,7 +1076,7 @@ class Uploader:
|
|
|
830
1076
|
history_id,
|
|
831
1077
|
)
|
|
832
1078
|
continue
|
|
833
|
-
self._post_attachment(filename, content, result_id)
|
|
1079
|
+
self._post_attachment(filename, content, result_id, _SPILL_CONTENT_TYPE)
|
|
834
1080
|
|
|
835
1081
|
def _try_associate_containers(self) -> None:
|
|
836
1082
|
"""Try to associate buffered containers with known results."""
|
|
@@ -1349,3 +1349,431 @@ class TestUploaderDrainProgressBased:
|
|
|
1349
1349
|
assert fully_drained is False, (
|
|
1350
1350
|
"drain reported success despite a stalled consumer with items queued"
|
|
1351
1351
|
)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
class TestAttachmentTransportRouting:
|
|
1355
|
+
"""Attachments route by size: small -> API-GW multipart, large -> presigned S3.
|
|
1356
|
+
|
|
1357
|
+
The oversized-field overflow fix: a result field (error_message / stack_trace)
|
|
1358
|
+
too large to carry inline is already spilled to an attachment, but that spill
|
|
1359
|
+
(and any file attachment) larger than the API-Gateway payload ceiling (~7.5MB
|
|
1360
|
+
effective) used to 413 on the multipart route and be lost. Content above the
|
|
1361
|
+
routing threshold is now uploaded straight to S3 over a presigned PUT
|
|
1362
|
+
(presign -> PUT -> complete), bypassing API Gateway so the size is bounded by
|
|
1363
|
+
S3 / the backend single-attachment policy, not by API Gateway.
|
|
1364
|
+
|
|
1365
|
+
These tests drive the uploader with a small threshold (set via env, resolved
|
|
1366
|
+
at construction) so the large-content path is exercised without allocating
|
|
1367
|
+
7.5MB+ buffers, plus one true >10MB end-to-end case for the acceptance
|
|
1368
|
+
criterion.
|
|
1369
|
+
"""
|
|
1370
|
+
|
|
1371
|
+
LAUNCH = "launch-1"
|
|
1372
|
+
PRESIGN_URL = f"https://charisma.test/api/v1/launches/{LAUNCH}/attachments/presign"
|
|
1373
|
+
COMPLETE_URL = f"https://charisma.test/api/v1/launches/{LAUNCH}/attachments/complete"
|
|
1374
|
+
APIGW_URL = f"https://charisma.test/api/v1/launches/{LAUNCH}/attachments"
|
|
1375
|
+
RESULTS_URL = f"https://charisma.test/api/v1/launches/{LAUNCH}/results"
|
|
1376
|
+
S3_PUT_URL = "https://s3.amazonaws.com/charisma-bucket/attachments/p/l/r/obj.txt?sig=abc"
|
|
1377
|
+
|
|
1378
|
+
def _write_json(self, path: Path, data: dict) -> None:
|
|
1379
|
+
import json
|
|
1380
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
1381
|
+
|
|
1382
|
+
def _config_with_threshold(self, monkeypatch, threshold_bytes: int) -> Config:
|
|
1383
|
+
"""Build a config and set the env-driven routing threshold.
|
|
1384
|
+
|
|
1385
|
+
The threshold is read at Uploader construction, so the env var must be
|
|
1386
|
+
set before the Uploader is created.
|
|
1387
|
+
"""
|
|
1388
|
+
monkeypatch.setenv(
|
|
1389
|
+
"CHARISMA_ATTACHMENT_APIGW_SAFE_BYTES", str(threshold_bytes)
|
|
1390
|
+
)
|
|
1391
|
+
return _make_config()
|
|
1392
|
+
|
|
1393
|
+
def _presign_response(self) -> httpx.Response:
|
|
1394
|
+
return httpx.Response(
|
|
1395
|
+
201,
|
|
1396
|
+
json={
|
|
1397
|
+
"uploadUrl": self.S3_PUT_URL,
|
|
1398
|
+
"attachmentId": "att-presigned-1",
|
|
1399
|
+
"s3Key": "attachments/p/l/r/obj.txt",
|
|
1400
|
+
},
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
def _no_backoff(self, monkeypatch) -> None:
|
|
1404
|
+
monkeypatch.setattr("charisma_cli.uploader.exponential_backoff", lambda attempt: 0.0)
|
|
1405
|
+
|
|
1406
|
+
@respx.mock
|
|
1407
|
+
def test_small_attachment_uses_apigw_route(self, tmp_path: Path, monkeypatch) -> None:
|
|
1408
|
+
"""Content at/below the threshold takes the unchanged API-GW multipart route."""
|
|
1409
|
+
config = self._config_with_threshold(monkeypatch, threshold_bytes=1024)
|
|
1410
|
+
queue: PriorityQueue = PriorityQueue()
|
|
1411
|
+
|
|
1412
|
+
respx.post(self.RESULTS_URL).mock(
|
|
1413
|
+
return_value=httpx.Response(200, json={
|
|
1414
|
+
"accepted": 1,
|
|
1415
|
+
"results": [{"testId": "hist-s", "resultId": "rid-s"}],
|
|
1416
|
+
})
|
|
1417
|
+
)
|
|
1418
|
+
apigw_route = respx.post(self.APIGW_URL).mock(
|
|
1419
|
+
return_value=httpx.Response(201, json={"id": "att-apigw"})
|
|
1420
|
+
)
|
|
1421
|
+
presign_route = respx.post(self.PRESIGN_URL).mock(
|
|
1422
|
+
return_value=self._presign_response()
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
result_file = tmp_path / "s-result.json"
|
|
1426
|
+
self._write_json(result_file, {
|
|
1427
|
+
"uuid": "s", "historyId": "hist-s", "fullName": "t.s",
|
|
1428
|
+
"status": "passed", "start": 1000, "stop": 2000,
|
|
1429
|
+
"attachments": [{"source": "small.png", "name": "small", "type": "image/png"}],
|
|
1430
|
+
})
|
|
1431
|
+
attach_file = tmp_path / "small.png"
|
|
1432
|
+
attach_file.write_bytes(b"x" * 512) # below 1024 threshold
|
|
1433
|
+
|
|
1434
|
+
uploader = Uploader(config, queue)
|
|
1435
|
+
uploader._launch_id = self.LAUNCH
|
|
1436
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
1437
|
+
queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
|
|
1438
|
+
|
|
1439
|
+
uploader.start()
|
|
1440
|
+
uploader.drain(timeout=5.0)
|
|
1441
|
+
uploader.stop()
|
|
1442
|
+
|
|
1443
|
+
assert uploader.summary.attachments_sent == 1
|
|
1444
|
+
assert apigw_route.called
|
|
1445
|
+
assert not presign_route.called, "small attachment must NOT use the S3 path"
|
|
1446
|
+
|
|
1447
|
+
@respx.mock
|
|
1448
|
+
def test_large_attachment_uses_presigned_s3_route_in_order(
|
|
1449
|
+
self, tmp_path: Path, monkeypatch
|
|
1450
|
+
) -> None:
|
|
1451
|
+
"""Content above the threshold goes presign -> PUT S3 -> complete, in order."""
|
|
1452
|
+
self._no_backoff(monkeypatch)
|
|
1453
|
+
config = self._config_with_threshold(monkeypatch, threshold_bytes=1024)
|
|
1454
|
+
queue: PriorityQueue = PriorityQueue()
|
|
1455
|
+
|
|
1456
|
+
call_order: list[str] = []
|
|
1457
|
+
|
|
1458
|
+
respx.post(self.RESULTS_URL).mock(
|
|
1459
|
+
return_value=httpx.Response(200, json={
|
|
1460
|
+
"accepted": 1,
|
|
1461
|
+
"results": [{"testId": "hist-l", "resultId": "rid-l"}],
|
|
1462
|
+
})
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
def _presign(request: httpx.Request) -> httpx.Response:
|
|
1466
|
+
call_order.append("presign")
|
|
1467
|
+
return self._presign_response()
|
|
1468
|
+
|
|
1469
|
+
def _put(request: httpx.Request) -> httpx.Response:
|
|
1470
|
+
call_order.append("put")
|
|
1471
|
+
return httpx.Response(200)
|
|
1472
|
+
|
|
1473
|
+
def _complete(request: httpx.Request) -> httpx.Response:
|
|
1474
|
+
call_order.append("complete")
|
|
1475
|
+
return httpx.Response(201, json={"attachmentId": "att-presigned-1"})
|
|
1476
|
+
|
|
1477
|
+
presign_route = respx.post(self.PRESIGN_URL).mock(side_effect=_presign)
|
|
1478
|
+
put_route = respx.put(self.S3_PUT_URL).mock(side_effect=_put)
|
|
1479
|
+
complete_route = respx.post(self.COMPLETE_URL).mock(side_effect=_complete)
|
|
1480
|
+
apigw_route = respx.post(self.APIGW_URL).mock(
|
|
1481
|
+
return_value=httpx.Response(201, json={"id": "att-apigw"})
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
result_file = tmp_path / "l-result.json"
|
|
1485
|
+
self._write_json(result_file, {
|
|
1486
|
+
"uuid": "l", "historyId": "hist-l", "fullName": "t.l",
|
|
1487
|
+
"status": "passed", "start": 1000, "stop": 2000,
|
|
1488
|
+
"attachments": [{"source": "big.bin", "name": "big", "type": "application/octet-stream"}],
|
|
1489
|
+
})
|
|
1490
|
+
attach_file = tmp_path / "big.bin"
|
|
1491
|
+
attach_file.write_bytes(b"y" * 4096) # above 1024 threshold
|
|
1492
|
+
|
|
1493
|
+
uploader = Uploader(config, queue)
|
|
1494
|
+
uploader._launch_id = self.LAUNCH
|
|
1495
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
1496
|
+
queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
|
|
1497
|
+
|
|
1498
|
+
uploader.start()
|
|
1499
|
+
uploader.drain(timeout=5.0)
|
|
1500
|
+
uploader.stop()
|
|
1501
|
+
|
|
1502
|
+
assert uploader.summary.attachments_sent == 1
|
|
1503
|
+
assert uploader.summary.attachments_skipped == 0
|
|
1504
|
+
assert call_order == ["presign", "put", "complete"]
|
|
1505
|
+
assert presign_route.called and put_route.called and complete_route.called
|
|
1506
|
+
assert not apigw_route.called, "large attachment must NOT use the API-GW route"
|
|
1507
|
+
|
|
1508
|
+
@respx.mock
|
|
1509
|
+
def test_s3_put_carries_no_authorization_and_signed_content_type(
|
|
1510
|
+
self, tmp_path: Path, monkeypatch
|
|
1511
|
+
) -> None:
|
|
1512
|
+
"""The direct S3 PUT must not leak the Bearer token and must echo the signed Content-Type.
|
|
1513
|
+
|
|
1514
|
+
The shared client carries a client-level Authorization header that httpx
|
|
1515
|
+
merges into every request; the PUT must go through a separate client so
|
|
1516
|
+
the token never reaches S3. The PUT Content-Type must equal the string
|
|
1517
|
+
sent to presign byte-for-byte (else S3 returns 403 SignatureDoesNotMatch).
|
|
1518
|
+
"""
|
|
1519
|
+
self._no_backoff(monkeypatch)
|
|
1520
|
+
config = self._config_with_threshold(monkeypatch, threshold_bytes=1024)
|
|
1521
|
+
queue: PriorityQueue = PriorityQueue()
|
|
1522
|
+
|
|
1523
|
+
respx.post(self.RESULTS_URL).mock(
|
|
1524
|
+
return_value=httpx.Response(200, json={
|
|
1525
|
+
"accepted": 1,
|
|
1526
|
+
"results": [{"testId": "hist-a", "resultId": "rid-a"}],
|
|
1527
|
+
})
|
|
1528
|
+
)
|
|
1529
|
+
presign_route = respx.post(self.PRESIGN_URL).mock(
|
|
1530
|
+
return_value=self._presign_response()
|
|
1531
|
+
)
|
|
1532
|
+
put_route = respx.put(self.S3_PUT_URL).mock(return_value=httpx.Response(200))
|
|
1533
|
+
respx.post(self.COMPLETE_URL).mock(
|
|
1534
|
+
return_value=httpx.Response(201, json={"attachmentId": "att-presigned-1"})
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
result_file = tmp_path / "a-result.json"
|
|
1538
|
+
self._write_json(result_file, {
|
|
1539
|
+
"uuid": "a", "historyId": "hist-a", "fullName": "t.a",
|
|
1540
|
+
"status": "failed", "start": 1000, "stop": 2000,
|
|
1541
|
+
"statusDetails": {"message": "Z" * (12 * 1024), "trace": None},
|
|
1542
|
+
})
|
|
1543
|
+
|
|
1544
|
+
uploader = Uploader(config, queue)
|
|
1545
|
+
uploader._launch_id = self.LAUNCH
|
|
1546
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
1547
|
+
|
|
1548
|
+
uploader.start()
|
|
1549
|
+
uploader.drain(timeout=5.0)
|
|
1550
|
+
uploader.stop()
|
|
1551
|
+
|
|
1552
|
+
assert uploader.summary.attachments_sent == 1
|
|
1553
|
+
|
|
1554
|
+
# The spill content type the client fixed and sent to presign.
|
|
1555
|
+
presign_body = __import__("json").loads(presign_route.calls[0].request.content)
|
|
1556
|
+
signed_ct = presign_body["contentType"]
|
|
1557
|
+
|
|
1558
|
+
put_request = put_route.calls[0].request
|
|
1559
|
+
assert "authorization" not in {k.lower() for k in put_request.headers.keys()}, (
|
|
1560
|
+
"Bearer token leaked to S3 on the presigned PUT"
|
|
1561
|
+
)
|
|
1562
|
+
assert put_request.headers["content-type"] == signed_ct, (
|
|
1563
|
+
"PUT Content-Type must match the presign-signed value byte-for-byte"
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
@respx.mock
|
|
1567
|
+
def test_file_attachment_unknown_extension_coerces_content_type_before_presign(
|
|
1568
|
+
self, tmp_path: Path, monkeypatch
|
|
1569
|
+
) -> None:
|
|
1570
|
+
"""A file with no recognizable extension is signed/sent as application/octet-stream.
|
|
1571
|
+
|
|
1572
|
+
guess_type returns None for an unknown extension; the client must coerce
|
|
1573
|
+
to a concrete MIME BEFORE presign so the signed and PUT Content-Type match.
|
|
1574
|
+
"""
|
|
1575
|
+
self._no_backoff(monkeypatch)
|
|
1576
|
+
config = self._config_with_threshold(monkeypatch, threshold_bytes=1024)
|
|
1577
|
+
queue: PriorityQueue = PriorityQueue()
|
|
1578
|
+
|
|
1579
|
+
respx.post(self.RESULTS_URL).mock(
|
|
1580
|
+
return_value=httpx.Response(200, json={
|
|
1581
|
+
"accepted": 1,
|
|
1582
|
+
"results": [{"testId": "hist-u", "resultId": "rid-u"}],
|
|
1583
|
+
})
|
|
1584
|
+
)
|
|
1585
|
+
presign_route = respx.post(self.PRESIGN_URL).mock(
|
|
1586
|
+
return_value=self._presign_response()
|
|
1587
|
+
)
|
|
1588
|
+
put_route = respx.put(self.S3_PUT_URL).mock(return_value=httpx.Response(200))
|
|
1589
|
+
respx.post(self.COMPLETE_URL).mock(
|
|
1590
|
+
return_value=httpx.Response(201, json={"attachmentId": "att-presigned-1"})
|
|
1591
|
+
)
|
|
1592
|
+
|
|
1593
|
+
result_file = tmp_path / "u-result.json"
|
|
1594
|
+
self._write_json(result_file, {
|
|
1595
|
+
"uuid": "u", "historyId": "hist-u", "fullName": "t.u",
|
|
1596
|
+
"status": "passed", "start": 1000, "stop": 2000,
|
|
1597
|
+
"attachments": [{"source": "blob.weirdext", "name": "blob", "type": "x/y"}],
|
|
1598
|
+
})
|
|
1599
|
+
attach_file = tmp_path / "blob.weirdext"
|
|
1600
|
+
attach_file.write_bytes(b"z" * 4096)
|
|
1601
|
+
|
|
1602
|
+
uploader = Uploader(config, queue)
|
|
1603
|
+
uploader._launch_id = self.LAUNCH
|
|
1604
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
1605
|
+
queue.put(FileEvent(path=attach_file, category=FileCategory.ATTACHMENT))
|
|
1606
|
+
|
|
1607
|
+
uploader.start()
|
|
1608
|
+
uploader.drain(timeout=5.0)
|
|
1609
|
+
uploader.stop()
|
|
1610
|
+
|
|
1611
|
+
assert uploader.summary.attachments_sent == 1
|
|
1612
|
+
presign_body = __import__("json").loads(presign_route.calls[0].request.content)
|
|
1613
|
+
assert presign_body["contentType"] == "application/octet-stream"
|
|
1614
|
+
put_request = put_route.calls[0].request
|
|
1615
|
+
assert put_request.headers["content-type"] == "application/octet-stream"
|
|
1616
|
+
|
|
1617
|
+
@respx.mock
|
|
1618
|
+
def test_presign_terminal_failure_counts_skipped(self, tmp_path: Path, monkeypatch) -> None:
|
|
1619
|
+
"""A non-retryable presign failure (e.g. 413) counts the attachment skipped, warns."""
|
|
1620
|
+
self._no_backoff(monkeypatch)
|
|
1621
|
+
config = self._config_with_threshold(monkeypatch, threshold_bytes=1024)
|
|
1622
|
+
queue: PriorityQueue = PriorityQueue()
|
|
1623
|
+
|
|
1624
|
+
respx.post(self.RESULTS_URL).mock(
|
|
1625
|
+
return_value=httpx.Response(200, json={
|
|
1626
|
+
"accepted": 1,
|
|
1627
|
+
"results": [{"testId": "hist-p", "resultId": "rid-p"}],
|
|
1628
|
+
})
|
|
1629
|
+
)
|
|
1630
|
+
presign_route = respx.post(self.PRESIGN_URL).mock(
|
|
1631
|
+
return_value=httpx.Response(413, json={"error": "too large"})
|
|
1632
|
+
)
|
|
1633
|
+
put_route = respx.put(self.S3_PUT_URL).mock(return_value=httpx.Response(200))
|
|
1634
|
+
|
|
1635
|
+
result_file = tmp_path / "p-result.json"
|
|
1636
|
+
self._write_json(result_file, {
|
|
1637
|
+
"uuid": "p", "historyId": "hist-p", "fullName": "t.p",
|
|
1638
|
+
"status": "failed", "start": 1000, "stop": 2000,
|
|
1639
|
+
"statusDetails": {"message": "Z" * (12 * 1024), "trace": None},
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
uploader = Uploader(config, queue)
|
|
1643
|
+
uploader._launch_id = self.LAUNCH
|
|
1644
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
1645
|
+
|
|
1646
|
+
uploader.start()
|
|
1647
|
+
uploader.drain(timeout=5.0)
|
|
1648
|
+
uploader.stop()
|
|
1649
|
+
|
|
1650
|
+
assert uploader.summary.attachments_sent == 0
|
|
1651
|
+
assert uploader.summary.attachments_skipped == 1
|
|
1652
|
+
assert presign_route.called
|
|
1653
|
+
assert not put_route.called, "no PUT should happen after presign fails"
|
|
1654
|
+
|
|
1655
|
+
@respx.mock
|
|
1656
|
+
def test_s3_put_retries_transient_then_succeeds(self, tmp_path: Path, monkeypatch) -> None:
|
|
1657
|
+
"""A transient 503 on the S3 PUT is retried with the shared backoff, then succeeds."""
|
|
1658
|
+
self._no_backoff(monkeypatch)
|
|
1659
|
+
config = self._config_with_threshold(monkeypatch, threshold_bytes=1024)
|
|
1660
|
+
queue: PriorityQueue = PriorityQueue()
|
|
1661
|
+
|
|
1662
|
+
respx.post(self.RESULTS_URL).mock(
|
|
1663
|
+
return_value=httpx.Response(200, json={
|
|
1664
|
+
"accepted": 1,
|
|
1665
|
+
"results": [{"testId": "hist-r", "resultId": "rid-r"}],
|
|
1666
|
+
})
|
|
1667
|
+
)
|
|
1668
|
+
respx.post(self.PRESIGN_URL).mock(return_value=self._presign_response())
|
|
1669
|
+
put_route = respx.put(self.S3_PUT_URL).mock(side_effect=[
|
|
1670
|
+
httpx.Response(503),
|
|
1671
|
+
httpx.Response(200),
|
|
1672
|
+
])
|
|
1673
|
+
complete_route = respx.post(self.COMPLETE_URL).mock(
|
|
1674
|
+
return_value=httpx.Response(201, json={"attachmentId": "att-presigned-1"})
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
result_file = tmp_path / "r-result.json"
|
|
1678
|
+
self._write_json(result_file, {
|
|
1679
|
+
"uuid": "r", "historyId": "hist-r", "fullName": "t.r",
|
|
1680
|
+
"status": "failed", "start": 1000, "stop": 2000,
|
|
1681
|
+
"statusDetails": {"message": "Z" * (12 * 1024), "trace": None},
|
|
1682
|
+
})
|
|
1683
|
+
|
|
1684
|
+
uploader = Uploader(config, queue)
|
|
1685
|
+
uploader._launch_id = self.LAUNCH
|
|
1686
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
1687
|
+
|
|
1688
|
+
uploader.start()
|
|
1689
|
+
uploader.drain(timeout=5.0)
|
|
1690
|
+
uploader.stop()
|
|
1691
|
+
|
|
1692
|
+
assert uploader.summary.attachments_sent == 1
|
|
1693
|
+
assert uploader.summary.attachments_skipped == 0
|
|
1694
|
+
assert put_route.call_count == 2 # retried once
|
|
1695
|
+
assert complete_route.called
|
|
1696
|
+
|
|
1697
|
+
@respx.mock
|
|
1698
|
+
def test_oversized_10mb_field_truncates_results_and_sends_full_attachment_via_s3(
|
|
1699
|
+
self, tmp_path: Path, monkeypatch
|
|
1700
|
+
) -> None:
|
|
1701
|
+
"""Acceptance: a >10MB stdout-like field reaches Charisma end-to-end.
|
|
1702
|
+
|
|
1703
|
+
- The core result still lands via /results (inline field truncated below
|
|
1704
|
+
the backend contract, so the batch is NOT rejected and the circuit
|
|
1705
|
+
breaker never trips on size).
|
|
1706
|
+
- The FULL >10MB content is uploaded as an attachment over the presigned
|
|
1707
|
+
S3 path (bounded by S3, not API Gateway).
|
|
1708
|
+
"""
|
|
1709
|
+
import json as _json
|
|
1710
|
+
# Use the real default threshold (5MB) — the 10MB field must exceed it
|
|
1711
|
+
# and therefore take the S3 path. Do NOT override the env here.
|
|
1712
|
+
monkeypatch.delenv("CHARISMA_ATTACHMENT_APIGW_SAFE_BYTES", raising=False)
|
|
1713
|
+
self._no_backoff(monkeypatch)
|
|
1714
|
+
config = _make_config()
|
|
1715
|
+
queue: PriorityQueue = PriorityQueue()
|
|
1716
|
+
|
|
1717
|
+
from charisma_cli.models import MAX_ERROR_MESSAGE_BYTES
|
|
1718
|
+
|
|
1719
|
+
big_field = "S" * (10 * 1024 * 1024 + 7) # > 10 MB
|
|
1720
|
+
|
|
1721
|
+
results_route = respx.post(self.RESULTS_URL).mock(
|
|
1722
|
+
return_value=httpx.Response(200, json={
|
|
1723
|
+
"accepted": 1,
|
|
1724
|
+
"results": [{"testId": "hist-10mb", "resultId": "rid-10mb"}],
|
|
1725
|
+
})
|
|
1726
|
+
)
|
|
1727
|
+
presign_route = respx.post(self.PRESIGN_URL).mock(
|
|
1728
|
+
return_value=self._presign_response()
|
|
1729
|
+
)
|
|
1730
|
+
put_bodies: list[bytes] = []
|
|
1731
|
+
|
|
1732
|
+
def _put(request: httpx.Request) -> httpx.Response:
|
|
1733
|
+
put_bodies.append(request.content)
|
|
1734
|
+
return httpx.Response(200)
|
|
1735
|
+
|
|
1736
|
+
put_route = respx.put(self.S3_PUT_URL).mock(side_effect=_put)
|
|
1737
|
+
complete_route = respx.post(self.COMPLETE_URL).mock(
|
|
1738
|
+
return_value=httpx.Response(201, json={"attachmentId": "att-presigned-1"})
|
|
1739
|
+
)
|
|
1740
|
+
apigw_route = respx.post(self.APIGW_URL).mock(
|
|
1741
|
+
return_value=httpx.Response(201, json={"id": "att-apigw"})
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
result_file = tmp_path / "huge-result.json"
|
|
1745
|
+
self._write_json(result_file, {
|
|
1746
|
+
"uuid": "u10", "historyId": "hist-10mb", "fullName": "t.huge",
|
|
1747
|
+
"status": "failed", "start": 1000, "stop": 2000,
|
|
1748
|
+
"statusDetails": {"message": big_field, "trace": None},
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
uploader = Uploader(config, queue)
|
|
1752
|
+
uploader._launch_id = self.LAUNCH
|
|
1753
|
+
queue.put(FileEvent(path=result_file, category=FileCategory.RESULT))
|
|
1754
|
+
|
|
1755
|
+
uploader.start()
|
|
1756
|
+
uploader.drain(timeout=10.0)
|
|
1757
|
+
uploader.stop()
|
|
1758
|
+
|
|
1759
|
+
# Core result landed: /results was accepted with a truncated inline field.
|
|
1760
|
+
assert uploader.summary.results_sent == 1
|
|
1761
|
+
assert uploader.summary.results_failed == 0
|
|
1762
|
+
assert not uploader._disabled, "circuit breaker must not trip on an oversized field"
|
|
1763
|
+
|
|
1764
|
+
sent = _json.loads(results_route.calls[0].request.content)["results"][0]
|
|
1765
|
+
assert len(sent["error_message"].encode("utf-8")) <= MAX_ERROR_MESSAGE_BYTES
|
|
1766
|
+
assert "attachment" in sent["error_message"].lower()
|
|
1767
|
+
|
|
1768
|
+
# Full content delivered over S3, not the API-GW route.
|
|
1769
|
+
assert uploader.summary.attachments_sent == 1
|
|
1770
|
+
assert uploader.summary.attachments_skipped == 0
|
|
1771
|
+
assert presign_route.called and put_route.called and complete_route.called
|
|
1772
|
+
assert not apigw_route.called
|
|
1773
|
+
assert put_bodies and put_bodies[0] == big_field.encode("utf-8"), (
|
|
1774
|
+
"the full >10MB field content must be the S3 PUT body"
|
|
1775
|
+
)
|
|
1776
|
+
# presign declared the true size so the backend bounds it against the
|
|
1777
|
+
# S3/domain policy.
|
|
1778
|
+
presign_body = _json.loads(presign_route.calls[0].request.content)
|
|
1779
|
+
assert presign_body["size"] == len(big_field.encode("utf-8"))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|