charisma-cli 0.5.0__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.
Files changed (38) hide show
  1. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/PKG-INFO +1 -1
  2. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/pyproject.toml +1 -1
  3. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/__init__.py +1 -1
  4. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/read_client.py +7 -0
  5. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/uploader.py +263 -17
  6. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_read_client_launches.py +9 -3
  7. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_uploader.py +428 -0
  8. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/.gitignore +0 -0
  9. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/README.md +0 -0
  10. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/config.py +0 -0
  11. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/launch_url.py +0 -0
  12. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/main.py +0 -0
  13. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/models.py +0 -0
  14. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/parser.py +0 -0
  15. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/read_models.py +0 -0
  16. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/retry.py +0 -0
  17. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/subprocess_mgr.py +0 -0
  18. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/src/charisma_cli/watcher.py +0 -0
  19. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/__init__.py +0 -0
  20. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/conftest.py +0 -0
  21. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_bamboo_labels.py +0 -0
  22. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_cli.py +0 -0
  23. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_config.py +0 -0
  24. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_integration.py +0 -0
  25. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_launch_url.py +0 -0
  26. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_models.py +0 -0
  27. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_open_launch_payload.py +0 -0
  28. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_parser.py +0 -0
  29. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_read_client.py +0 -0
  30. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_read_client_attachments.py +0 -0
  31. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_read_client_integration.py +0 -0
  32. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_read_client_pagination.py +0 -0
  33. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_read_client_results.py +0 -0
  34. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_read_client_single_result.py +0 -0
  35. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_retry.py +0 -0
  36. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_silent_mode.py +0 -0
  37. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_subprocess_mgr.py +0 -0
  38. {charisma_cli-0.5.0 → charisma_cli-0.5.2}/tests/test_watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: CLI tool that watches allure-results and streams test results + attachments to Charisma.
5
5
  Author: Charisma Team
6
6
  License-Expression: MIT
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "charisma-cli"
8
- version = "0.5.0"
8
+ version = "0.5.2"
9
9
  description = "CLI tool that watches allure-results and streams test results + attachments to Charisma."
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -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.0"
7
+ __version__ = "0.5.2"
@@ -296,6 +296,7 @@ class CharismaReadClient:
296
296
  build_id: str | None = None,
297
297
  commit_sha: str | None = None,
298
298
  source_url: str | None = None,
299
+ component_id: str | None = None,
299
300
  started_at_from: str | None = None,
300
301
  started_at_to: str | None = None,
301
302
  ) -> PaginatedLaunchList:
@@ -317,6 +318,11 @@ class CharismaReadClient:
317
318
  build_id: Optional build id filter (wire name ``buildId``).
318
319
  commit_sha: Optional commit SHA filter (wire name ``commitSha``).
319
320
  source_url: Optional source URL filter (wire name ``sourceUrl``).
321
+ component_id: Optional component UUID filter (wire name ``componentId``).
322
+ Matches a launch by its primary component, its component set, or a
323
+ build-plan-linked component. The sentinel ``"_unassigned"`` selects
324
+ launches with no component. A non-UUID value (other than the
325
+ sentinel) makes the server reject the request with HTTP 400.
320
326
  started_at_from: Optional lower bound on ``startedAt`` (wire name ``startedAtFrom``).
321
327
  started_at_to: Optional upper bound on ``startedAt`` (wire name ``startedAtTo``).
322
328
 
@@ -336,6 +342,7 @@ class CharismaReadClient:
336
342
  "buildId": build_id,
337
343
  "commitSha": commit_sha,
338
344
  "sourceUrl": source_url,
345
+ "componentId": component_id,
339
346
  "startedAtFrom": started_at_from,
340
347
  "startedAtTo": started_at_to,
341
348
  }
@@ -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
- self._post_attachment(path.name, content, result_id)
744
-
745
- def _post_attachment(self, filename: str, content: bytes, result_id: str) -> None:
746
- """POST attachment bytes to the Charisma attachment API with retry.
747
-
748
- Shared HTTP core for both file-backed attachments (``_upload_attachment``)
749
- and in-memory field spills (``_flush_pending_spills``). ``result_id`` is
750
- the backend-assigned ``test_results.id`` from the append-results response
751
- (ADR-031), sent as the ``resultId`` form field and matched exactly by the
752
- backend. Transient failures (5xx, 429, connect/timeout) are retried with
753
- exponential backoff; terminal failures count the attachment skipped and
754
- log at WARNING (a silent DEBUG-only skip is what hid the original
755
- attachments-skipped bug).
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 sent in the multipart body.
817
+ filename: Attachment filename.
759
818
  content: Raw attachment bytes.
760
- result_id: Backend-assigned ``test_results.id`` for the owning result
761
- (from the append-results response), sent as ``resultId``.
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."""
@@ -5,8 +5,9 @@ contract (per design.md "Client" + "Path & transport", requirements.md FR-2, and
5
5
  contract in `.agents/tasks/charisma-cli-read-endpoint-contracts.md`):
6
6
 
7
7
  - ``list_launches`` → ``GET /api/v1/projects/{pid}/launches`` with camelCase query params. Forwards
8
- ALL 9 exact-match filters with their camelCase wire names (``name, status, branch, environment,
9
- buildId, commitSha, sourceUrl, startedAtFrom, startedAtTo``) plus ``offset``/``limit``. Exposes
8
+ ALL exact-match filters with their camelCase wire names (``name, status, branch, environment,
9
+ buildId, commitSha, sourceUrl, componentId, startedAtFrom, startedAtTo``) plus ``offset``/``limit``.
10
+ Exposes
10
11
  NO ``sortBy``/``sortOrder`` param — the server sort is FIXED newest-first. ``None``-valued filters
11
12
  are omitted from the query (not sent as empty). Returns the ``{items, total}`` envelope verbatim
12
13
  (camelCase ``BaseLaunch`` items).
@@ -230,7 +231,7 @@ class TestListLaunchesFiltersForwarded:
230
231
  """ALL 9 exact-match filters are forwarded with the correct camelCase wire names."""
231
232
 
232
233
  @respx.mock
233
- def test_all_nine_filters_forwarded_with_camelcase_wire_names(self) -> None:
234
+ def test_all_filters_forwarded_with_camelcase_wire_names(self) -> None:
234
235
  """Every filter maps to its camelCase query key with the provided value."""
235
236
  route = respx.get(LAUNCHES_URL).mock(
236
237
  return_value=httpx.Response(200, json=LAUNCHES_ENVELOPE)
@@ -244,6 +245,7 @@ class TestListLaunchesFiltersForwarded:
244
245
  build_id="build-4823",
245
246
  commit_sha="a1b2c3d4",
246
247
  source_url="https://ci.example.com/build/4823",
248
+ component_id="22222222-2222-2222-2222-222222222222",
247
249
  started_at_from="2026-06-01T00:00:00+00:00",
248
250
  started_at_to="2026-06-30T23:59:59+00:00",
249
251
  )
@@ -255,6 +257,7 @@ class TestListLaunchesFiltersForwarded:
255
257
  assert params["buildId"] == "build-4823"
256
258
  assert params["commitSha"] == "a1b2c3d4"
257
259
  assert params["sourceUrl"] == "https://ci.example.com/build/4823"
260
+ assert params["componentId"] == "22222222-2222-2222-2222-222222222222"
258
261
  assert params["startedAtFrom"] == "2026-06-01T00:00:00+00:00"
259
262
  assert params["startedAtTo"] == "2026-06-30T23:59:59+00:00"
260
263
 
@@ -269,6 +272,7 @@ class TestListLaunchesFiltersForwarded:
269
272
  build_id="build-4823",
270
273
  commit_sha="a1b2c3d4",
271
274
  source_url="https://ci.example.com/build/4823",
275
+ component_id="22222222-2222-2222-2222-222222222222",
272
276
  started_at_from="2026-06-01T00:00:00+00:00",
273
277
  started_at_to="2026-06-30T23:59:59+00:00",
274
278
  )
@@ -277,6 +281,7 @@ class TestListLaunchesFiltersForwarded:
277
281
  "build_id",
278
282
  "commit_sha",
279
283
  "source_url",
284
+ "component_id",
280
285
  "started_at_from",
281
286
  "started_at_to",
282
287
  ):
@@ -299,6 +304,7 @@ class TestListLaunchesFiltersForwarded:
299
304
  "buildId",
300
305
  "commitSha",
301
306
  "sourceUrl",
307
+ "componentId",
302
308
  "startedAtFrom",
303
309
  "startedAtTo",
304
310
  ):
@@ -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