mainsequence 9.0.8__tar.gz → 9.0.8.dev44__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 (103) hide show
  1. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/PKG-INFO +1 -1
  2. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/config.py +7 -3
  3. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/utils.py +19 -61
  4. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/code_repository_context.py +3 -3
  5. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/logconf.py +10 -29
  6. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence.egg-info/PKG-INFO +1 -1
  7. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence.egg-info/SOURCES.txt +0 -2
  8. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/pyproject.toml +1 -1
  9. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_auth_precedence.py +0 -1
  10. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_cli_secure_storage.py +0 -17
  11. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_code_repository_runtime_context.py +2 -13
  12. mainsequence-9.0.8/mainsequence/runtime_credential_exchange.py +0 -168
  13. mainsequence-9.0.8/tests/test_runtime_credential_exchange.py +0 -383
  14. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/LICENSE +0 -0
  15. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/README.md +0 -0
  16. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/AGENTS.md +0 -0
  17. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  18. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +0 -0
  19. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  20. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  21. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  22. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  23. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  24. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  25. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  26. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/__init__.py +0 -0
  27. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/__main__.py +0 -0
  28. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/_request_identity.py +0 -0
  29. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/bootstrap.py +0 -0
  30. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/__init__.py +0 -0
  31. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/api.py +0 -0
  32. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/browser_auth.py +0 -0
  33. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/cli.py +0 -0
  34. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/code_repository_status.py +0 -0
  35. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/docker_utils.py +0 -0
  36. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/doctor.py +0 -0
  37. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/local_ops.py +0 -0
  38. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/model_filters.py +0 -0
  39. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/pydantic_cli.py +0 -0
  40. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/sdk_utils.py +0 -0
  41. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/ssh_utils.py +0 -0
  42. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/cli/ui.py +0 -0
  43. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/__init__.py +0 -0
  44. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/_transport.py +0 -0
  45. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/agent_runtime_models.py +0 -0
  46. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/base.py +0 -0
  47. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/client.py +0 -0
  48. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/compute_validation.py +0 -0
  49. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/exceptions.py +0 -0
  50. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/github_issues.py +0 -0
  51. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/inference.py +0 -0
  52. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/models_foundry.py +0 -0
  53. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/models_helpers.py +0 -0
  54. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/models_user.py +0 -0
  55. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/observability.py +0 -0
  56. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/client/value_sets.py +0 -0
  57. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/code_repository_skills.py +0 -0
  58. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/defaults.py +0 -0
  59. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/instrumentation/__init__.py +0 -0
  60. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/instrumentation/utils.py +0 -0
  61. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/repository_identity_security.py +0 -0
  62. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/runtime_flags.py +0 -0
  63. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/scaffold_skills.py +0 -0
  64. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/server/__init__.py +0 -0
  65. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/server/caller_assertions.py +0 -0
  66. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence/server/fastapi.py +0 -0
  67. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence.egg-info/dependency_links.txt +0 -0
  68. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence.egg-info/entry_points.txt +0 -0
  69. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence.egg-info/requires.txt +0 -0
  70. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/mainsequence.egg-info/top_level.txt +0 -0
  71. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/setup.cfg +0 -0
  72. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_auth_bootstrap.py +0 -0
  73. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_caller_assertions.py +0 -0
  74. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_canonical_api_route_contract.py +0 -0
  75. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_cli_auth_contract.py +0 -0
  76. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_cli_browser_auth.py +0 -0
  77. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_client.py +0 -0
  78. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_code_repository_branch_contract.py +0 -0
  79. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_code_repository_documentation_contract.py +0 -0
  80. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_code_repository_skills.py +0 -0
  81. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_dependency_extras.py +0 -0
  82. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_dev_release_version.py +0 -0
  83. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_environment_resource_context.py +0 -0
  84. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_environment_response_contracts.py +0 -0
  85. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_fastapi_request_identity.py +0 -0
  86. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_github_issues_client.py +0 -0
  87. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_http_retry_budget.py +0 -0
  88. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_independent_context.py +0 -0
  89. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_inference_client.py +0 -0
  90. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_instrumentation.py +0 -0
  91. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_job_client.py +0 -0
  92. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_local_ops.py +0 -0
  93. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_logconf.py +0 -0
  94. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_models_user_request_bound_auth.py +0 -0
  95. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_notification_uid_contract.py +0 -0
  96. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_observability_client.py +0 -0
  97. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_scaffold_skills.py +0 -0
  98. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_secret_client_model.py +0 -0
  99. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_source_ownership.py +0 -0
  100. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_ssh_utils.py +0 -0
  101. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_tolerant_response_reading.py +0 -0
  102. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_tolerant_value_sets.py +0 -0
  103. {mainsequence-9.0.8 → mainsequence-9.0.8.dev44}/tests/test_user_application_facts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.8
3
+ Version: 9.0.8.dev44
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -29,7 +29,6 @@ from keyring import core as keyring_core
29
29
  from keyring import errors as keyring_errors
30
30
 
31
31
  from mainsequence.defaults import CANONICAL_BACKEND_ENV, STANDARD_BACKEND_URL
32
- from mainsequence.runtime_credential_exchange import runtime_credential_configured
33
32
 
34
33
  APP_NAME = "MainSequenceCLI"
35
34
 
@@ -805,8 +804,13 @@ def session_report() -> dict:
805
804
  refresh_expires_at = token_expiry(refresh)
806
805
 
807
806
  if auth_mode == "runtime_credential":
808
- # The credential is an ID with a bootstrap secret or an identity token file.
809
- authenticated = bool(access or runtime_credential_configured())
807
+ authenticated = bool(
808
+ access
809
+ or (
810
+ (os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip()
811
+ and (os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
812
+ )
813
+ )
810
814
  session_expires_at = None
811
815
  elif refresh:
812
816
  authenticated = refresh_expires_at is None or refresh_expires_at > now
@@ -21,14 +21,6 @@ from requests.structures import CaseInsensitiveDict
21
21
 
22
22
  from mainsequence.defaults import resolve_backend_endpoint
23
23
  from mainsequence.logconf import logger
24
- from mainsequence.runtime_credential_exchange import (
25
- RUNTIME_CREDENTIAL_ID_ENV,
26
- RUNTIME_CREDENTIAL_SECRET_ENV,
27
- RUNTIME_IDENTITY_TOKEN_FILE_ENV,
28
- RuntimeIdentityTokenError,
29
- exchange_runtime_credential,
30
- identity_token_file_from_environment,
31
- )
32
24
 
33
25
  from ._transport import DEFAULT_ALLOWED_METHODS as DEFAULT_ALLOWED_METHODS
34
26
  from ._transport import DEFAULT_STATUS_FORCELIST as DEFAULT_STATUS_FORCELIST
@@ -317,17 +309,6 @@ class SessionJWTAuthProvider(BaseAuthProvider):
317
309
 
318
310
  @dataclass
319
311
  class RuntimeCredentialAuthProvider(BaseAuthProvider):
320
- """
321
- Exchange the runtime credential the platform injected for short-lived access tokens.
322
-
323
- The proof is the projected workload identity token when
324
- `MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE` is set: the file is read for every
325
- exchange, and `MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET` is never read or sent.
326
- Otherwise the proof is that bootstrap secret. The token is never kept on the
327
- provider. Throttled (`429`) and temporarily unavailable (`503`) exchanges are
328
- retried within the timeout budget; a rejected one (`401`) fails at once.
329
- """
330
-
331
312
  credential_id: str | None = None
332
313
  credential_secret: str | None = None
333
314
  token_url: str | None = None
@@ -335,18 +316,13 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
335
316
  refresh_skew_seconds: int = 30
336
317
  timeout: tuple[float, float] = DEFAULT_TIMEOUT
337
318
  expires_at: float | None = None
338
- identity_token_file: str | None = None
339
319
  _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
340
320
 
341
321
  def __post_init__(self):
342
322
  if self.credential_id is None:
343
- self.credential_id = os.getenv(RUNTIME_CREDENTIAL_ID_ENV)
344
- if self.identity_token_file is None:
345
- self.identity_token_file = identity_token_file_from_environment()
346
- else:
347
- self.identity_token_file = str(self.identity_token_file).strip() or None
348
- if self.credential_secret is None and not self.identity_token_file:
349
- self.credential_secret = os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV)
323
+ self.credential_id = os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID")
324
+ if self.credential_secret is None:
325
+ self.credential_secret = os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET")
350
326
  if not self.token_url:
351
327
  self.token_url = f"{API_ENDPOINT}/runtime-credentials/token/"
352
328
 
@@ -368,48 +344,20 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
368
344
 
369
345
  return exp <= int(time.time()) + self.refresh_skew_seconds
370
346
 
371
- @staticmethod
372
- def _log_exchange_retry(status: int, delay: float) -> None:
373
- logger.warning(f"Runtime credential exchange returned {status}; retrying in {delay:.1f} s.")
374
-
375
- def _exchange(self) -> requests.Response:
347
+ def _require_credentials(self) -> tuple[str, str]:
376
348
  credential_id = (self.credential_id or "").strip()
377
- identity_token_file = self.identity_token_file
378
- credential_secret = None if identity_token_file else (self.credential_secret or "").strip()
349
+ credential_secret = (self.credential_secret or "").strip()
379
350
  if not credential_id:
380
351
  raise AuthError(
381
352
  "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID is required when "
382
353
  "MAINSEQUENCE_AUTH_MODE=runtime_credential."
383
354
  )
384
- if not identity_token_file and not credential_secret:
355
+ if not credential_secret:
385
356
  raise AuthError(
386
357
  "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET is required when "
387
- "MAINSEQUENCE_AUTH_MODE=runtime_credential and "
388
- f"{RUNTIME_IDENTITY_TOKEN_FILE_ENV} is not set."
358
+ "MAINSEQUENCE_AUTH_MODE=runtime_credential."
389
359
  )
390
- try:
391
- # One budget for every attempt and wait, inside the caller's budget if any.
392
- with request_budget(self.timeout) as budget:
393
-
394
- def post(body: dict[str, str]) -> requests.Response:
395
- return requests.post(
396
- self.token_url,
397
- json=body,
398
- headers={"Content-Type": "application/json"},
399
- timeout=budget.timeout(),
400
- allow_redirects=False,
401
- )
402
-
403
- return exchange_runtime_credential(
404
- post,
405
- credential_id=credential_id,
406
- credential_secret=credential_secret,
407
- identity_token_file=identity_token_file,
408
- deadline=budget.deadline,
409
- on_retry=self._log_exchange_retry,
410
- )
411
- except RuntimeIdentityTokenError as exc:
412
- raise AuthError(str(exc)) from None
360
+ return credential_id, credential_secret
413
361
 
414
362
  def refresh(
415
363
  self,
@@ -422,7 +370,17 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
422
370
  if not force and not self._needs_exchange():
423
371
  return
424
372
 
425
- response = self._exchange()
373
+ credential_id, credential_secret = self._require_credentials()
374
+ response = requests.post(
375
+ self.token_url,
376
+ json={
377
+ "credential_id": credential_id,
378
+ "credential_secret": credential_secret,
379
+ },
380
+ headers={"Content-Type": "application/json"},
381
+ timeout=remaining_timeout(self.timeout),
382
+ allow_redirects=False,
383
+ )
426
384
  if response.status_code < 200 or response.status_code >= 300:
427
385
  raise AuthError(
428
386
  f"Runtime credential exchange failed with status {response.status_code}."
@@ -10,8 +10,6 @@ from dataclasses import dataclass, replace
10
10
  from typing import Any, Literal
11
11
  from urllib.parse import urlsplit
12
12
 
13
- from mainsequence.runtime_credential_exchange import runtime_credential_configured
14
-
15
13
  CodeRepositoryContextStatus = Literal[
16
14
  "resolved",
17
15
  "code_repository_branch_not_registered",
@@ -206,7 +204,9 @@ def _exchange_authenticated_runtime_context_if_configured() -> None:
206
204
  return
207
205
  if (os.getenv("MAINSEQUENCE_AUTH_MODE") or "").strip().lower() != "runtime_credential":
208
206
  return
209
- if not runtime_credential_configured():
207
+ if not (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip():
208
+ return
209
+ if not (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip():
210
210
  return
211
211
 
212
212
  from mainsequence.client.utils import AuthError, loaders
@@ -5,7 +5,6 @@ import logging
5
5
  import logging.config
6
6
  import os
7
7
  import sys
8
- import time
9
8
  import traceback
10
9
  from collections.abc import Mapping
11
10
  from importlib.metadata import PackageNotFoundError
@@ -23,13 +22,6 @@ from structlog.stdlib import BoundLogger
23
22
  from .defaults import resolve_backend_endpoint
24
23
  from .instrumentation import OTelJSONRenderer
25
24
  from .repository_identity_security import UNSUPPORTED_SOURCE_IDENTITY_ENV_NAMES
26
- from .runtime_credential_exchange import (
27
- RUNTIME_CREDENTIAL_ID_ENV,
28
- RUNTIME_CREDENTIAL_SECRET_ENV,
29
- exchange_runtime_credential,
30
- identity_token_file_from_environment,
31
- runtime_credential_configured,
32
- )
33
25
  from .runtime_flags import is_running_in_pod
34
26
 
35
27
  logger = None
@@ -102,31 +94,20 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
102
94
  return headers, False
103
95
 
104
96
  def _exchange_runtime_credential() -> bool:
105
- if not runtime_credential_configured():
97
+ credential_id = (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip()
98
+ credential_secret = (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
99
+ if not credential_id or not credential_secret:
106
100
  return False
107
- identity_token_file = identity_token_file_from_environment()
108
- token_url = f"{_backend_base_url()}/api/v1/runtime-credentials/token/"
109
101
 
110
- def _post(body: dict[str, str]) -> requests.Response:
111
- return requests.post(
112
- token_url,
102
+ try:
103
+ token_resp = requests.post(
104
+ f"{_backend_base_url()}/api/v1/runtime-credentials/token/",
113
105
  headers={"Content-Type": "application/json"},
114
- json=body,
106
+ json={
107
+ "credential_id": credential_id,
108
+ "credential_secret": credential_secret,
109
+ },
115
110
  timeout=timeout_s,
116
- allow_redirects=False,
117
- )
118
-
119
- try:
120
- token_resp = exchange_runtime_credential(
121
- _post,
122
- credential_id=(os.getenv(RUNTIME_CREDENTIAL_ID_ENV) or "").strip(),
123
- credential_secret=(
124
- None
125
- if identity_token_file
126
- else (os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV) or "").strip()
127
- ),
128
- identity_token_file=identity_token_file,
129
- deadline=time.monotonic() + timeout_s,
130
111
  )
131
112
  except Exception:
132
113
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.8
3
+ Version: 9.0.8.dev44
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -20,7 +20,6 @@ mainsequence/code_repository_skills.py
20
20
  mainsequence/defaults.py
21
21
  mainsequence/logconf.py
22
22
  mainsequence/repository_identity_security.py
23
- mainsequence/runtime_credential_exchange.py
24
23
  mainsequence/runtime_flags.py
25
24
  mainsequence/scaffold_skills.py
26
25
  mainsequence.egg-info/PKG-INFO
@@ -91,7 +90,6 @@ tests/test_logconf.py
91
90
  tests/test_models_user_request_bound_auth.py
92
91
  tests/test_notification_uid_contract.py
93
92
  tests/test_observability_client.py
94
- tests/test_runtime_credential_exchange.py
95
93
  tests/test_scaffold_skills.py
96
94
  tests/test_secret_client_model.py
97
95
  tests/test_source_ownership.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.8"
7
+ version = "9.0.8.dev44"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -150,7 +150,6 @@ def test_logconf_runtime_credential_exchanges_before_startup_state_request(monke
150
150
  "credential_secret": "cred-secret",
151
151
  },
152
152
  "timeout": 10.0,
153
- "allow_redirects": False,
154
153
  }
155
154
  ]
156
155
  assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "runtime-access"
@@ -1021,20 +1021,3 @@ def test_session_report_for_a_runtime_credential_has_no_session_expiry(monkeypat
1021
1021
  assert report["session_expires_at"] is None
1022
1022
  assert report["auth_mode"] == "runtime_credential"
1023
1023
  assert "cred-secret" not in json.dumps(report)
1024
-
1025
-
1026
- def test_session_report_accepts_a_runtime_identity_token_file(monkeypatch, tmp_path):
1027
- _isolate_auth(monkeypatch, tmp_path, None)
1028
- monkeypatch.setattr(config, "auth_persistence_label", lambda: "test store")
1029
- token_file = tmp_path / "token"
1030
- token_file.write_text("projected-token", encoding="utf-8")
1031
- monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", "runtime_credential")
1032
- monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID", "cred-id")
1033
- monkeypatch.delenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", raising=False)
1034
- monkeypatch.setenv("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", str(token_file))
1035
-
1036
- report = config.session_report()
1037
-
1038
- assert report["authenticated"] is True
1039
- assert report["session_expires_at"] is None
1040
- assert "projected-token" not in json.dumps(report)
@@ -368,21 +368,10 @@ def test_authenticated_runtime_context_detects_git_drift(monkeypatch):
368
368
  code_repository_context.validate_code_repository_source_context(context=context)
369
369
 
370
370
 
371
- @pytest.mark.parametrize(
372
- ("proof_variable", "proof_value"),
373
- [
374
- ("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", "credential-secret"),
375
- ("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", "/var/run/secrets/runtime-identity/token"),
376
- ],
377
- )
378
- def test_configured_runtime_credential_exchanges_context_before_resolution(
379
- monkeypatch, proof_variable, proof_value
380
- ):
371
+ def test_configured_runtime_credential_exchanges_context_before_resolution(monkeypatch):
381
372
  monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", "runtime_credential")
382
373
  monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID", "credential-id")
383
- monkeypatch.delenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", raising=False)
384
- monkeypatch.delenv("MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE", raising=False)
385
- monkeypatch.setenv(proof_variable, proof_value)
374
+ monkeypatch.setenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET", "credential-secret")
386
375
  events = []
387
376
  monkeypatch.setattr(
388
377
  code_repository_context,
@@ -1,168 +0,0 @@
1
- """
2
- The runtime credential exchange, shared by the auth provider and the logger.
3
-
4
- A runtime started with ``MAINSEQUENCE_AUTH_MODE=runtime_credential`` exchanges its
5
- credential at ``POST /api/v1/runtime-credentials/token/`` for a short-lived access
6
- token. The request carries ``credential_id`` and exactly one proof:
7
-
8
- * ``workload_identity_token`` when ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE`` is
9
- set: the projected token read from that file for every exchange, because the
10
- file is rotated. The bootstrap secret is then never read or sent, and a missing,
11
- unreadable or empty file is an error, never a fallback to the secret.
12
- * ``credential_secret`` otherwise: ``MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET``.
13
-
14
- The token exists only inside one exchange request. It is not kept on any object,
15
- written to the environment or a file, logged, or included in an error message.
16
-
17
- The logger exchanges a runtime credential while it is being configured, so this
18
- module imports nothing from the SDK.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import os
24
- import time
25
- from collections.abc import Callable
26
- from typing import Any
27
-
28
- from urllib3.exceptions import InvalidHeader
29
- from urllib3.util import Retry
30
-
31
- RUNTIME_CREDENTIAL_ID_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID"
32
- RUNTIME_CREDENTIAL_SECRET_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET"
33
- RUNTIME_IDENTITY_TOKEN_FILE_ENV = "MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE"
34
-
35
- # 429: the exchange is throttled. 503: verification is temporarily unavailable.
36
- # Neither exchanged anything, so the same request may be sent again.
37
- RETRYABLE_EXCHANGE_STATUSES = frozenset({429, 503})
38
- EXCHANGE_RETRIES = 3
39
- EXCHANGE_BACKOFF_FACTOR = 0.5
40
-
41
- _RETRY_AFTER = Retry(total=0)
42
-
43
-
44
- class RuntimeIdentityTokenError(Exception):
45
- """The runtime identity token file cannot be used. The message never holds its content."""
46
-
47
-
48
- def _environment_value(name: str) -> str:
49
- return (os.getenv(name) or "").strip()
50
-
51
-
52
- def identity_token_file_from_environment() -> str | None:
53
- """Return the path in ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE``, or None when unset."""
54
- return _environment_value(RUNTIME_IDENTITY_TOKEN_FILE_ENV) or None
55
-
56
-
57
- def runtime_credential_configured() -> bool:
58
- """
59
- Say whether the environment holds a runtime credential to exchange.
60
-
61
- That is a credential ID and one proof: the identity token file, or else the
62
- bootstrap secret. Only the presence of the setting is checked. The token file
63
- is read by the exchange itself, which reports a missing or empty file.
64
- """
65
- if not _environment_value(RUNTIME_CREDENTIAL_ID_ENV):
66
- return False
67
- if identity_token_file_from_environment():
68
- return True
69
- return bool(_environment_value(RUNTIME_CREDENTIAL_SECRET_ENV))
70
-
71
-
72
- def _read_identity_token(path: str) -> str:
73
- source = f"The runtime identity token file {path} ({RUNTIME_IDENTITY_TOKEN_FILE_ENV})"
74
- try:
75
- with open(path, "rb") as token_file:
76
- raw = token_file.read()
77
- except FileNotFoundError:
78
- raise RuntimeIdentityTokenError(f"{source} does not exist.") from None
79
- except OSError as exc:
80
- raise RuntimeIdentityTokenError(
81
- f"{source} could not be read ({type(exc).__name__})."
82
- ) from None
83
- try:
84
- token = raw.decode("utf-8").strip()
85
- except UnicodeDecodeError:
86
- # Raised below, outside this handler: the decode error holds the bytes.
87
- token = None
88
- if token is None:
89
- raise RuntimeIdentityTokenError(f"{source} does not hold a token.")
90
- if not token:
91
- raise RuntimeIdentityTokenError(f"{source} is empty.")
92
- return token
93
-
94
-
95
- def _request_body(
96
- credential_id: str,
97
- credential_secret: str | None,
98
- identity_token_file: str | None,
99
- ) -> dict[str, str]:
100
- if identity_token_file:
101
- return {
102
- "credential_id": credential_id,
103
- "workload_identity_token": _read_identity_token(identity_token_file),
104
- }
105
- return {"credential_id": credential_id, "credential_secret": credential_secret or ""}
106
-
107
-
108
- def _retry_delay(response: Any, attempt: int) -> float:
109
- delay = min(EXCHANGE_BACKOFF_FACTOR * 2**attempt, Retry.DEFAULT_BACKOFF_MAX)
110
- retry_after = (getattr(response, "headers", None) or {}).get("Retry-After")
111
- if retry_after is not None:
112
- try:
113
- delay = max(delay, _RETRY_AFTER.parse_retry_after(str(retry_after)))
114
- except InvalidHeader:
115
- pass
116
- return delay
117
-
118
-
119
- def exchange_runtime_credential(
120
- send: Callable[[dict[str, str]], Any],
121
- *,
122
- credential_id: str,
123
- credential_secret: str | None,
124
- identity_token_file: str | None,
125
- deadline: float,
126
- on_retry: Callable[[int, float], None] | None = None,
127
- ) -> Any:
128
- """
129
- Send one runtime credential exchange and return the last response.
130
-
131
- Parameters
132
- ----------
133
- send:
134
- Posts the JSON body it is given to the exchange route and returns the
135
- response. It is called once per attempt.
136
- credential_id:
137
- The runtime credential ID.
138
- credential_secret:
139
- The bootstrap secret. Ignored when ``identity_token_file`` is set.
140
- identity_token_file:
141
- The projected token file. It is read again for every attempt.
142
- deadline:
143
- ``time.monotonic()`` value after which no retry starts.
144
- on_retry:
145
- Called with the status and the delay before each retry.
146
-
147
- A ``429`` or ``503`` answer is retried up to ``EXCHANGE_RETRIES`` times. The
148
- wait is the exponential backoff or the answer's ``Retry-After``, whichever is
149
- longer; a wait that would not end before ``deadline`` ends the retries. Any
150
- other answer, ``401`` included, is returned at once and the proof is never
151
- switched. ``RuntimeIdentityTokenError`` is raised when the token file cannot
152
- be used.
153
- """
154
- for attempt in range(EXCHANGE_RETRIES + 1):
155
- response = send(_request_body(credential_id, credential_secret, identity_token_file))
156
- status = response.status_code
157
- if status not in RETRYABLE_EXCHANGE_STATUSES or attempt == EXCHANGE_RETRIES:
158
- return response
159
- delay = _retry_delay(response, attempt)
160
- if delay >= deadline - time.monotonic():
161
- return response
162
- if on_retry is not None:
163
- on_retry(status, delay)
164
- close = getattr(response, "close", None)
165
- if callable(close):
166
- close()
167
- time.sleep(delay)
168
- raise AssertionError("Unreachable retry state")
@@ -1,383 +0,0 @@
1
- """Runtime credential exchange: the projected identity token, the secret, and retries."""
2
-
3
- from __future__ import annotations
4
-
5
- import importlib
6
- import logging
7
- import os
8
- import time
9
-
10
- import pytest
11
- import requests
12
- from requests.structures import CaseInsensitiveDict
13
-
14
- from tests._support import load_sdk_submodule
15
-
16
- pytestmark = pytest.mark.usefixtures("isolated_sdk_imports", "clean_runtime_context_environment")
17
-
18
- TOKEN_URL = "https://backend.example/api/v1/runtime-credentials/token/"
19
- ID_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID"
20
- SECRET_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET"
21
- TOKEN_FILE_ENV = "MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE"
22
- SECRET = "bootstrap-secret-that-must-not-be-sent"
23
- JOB_RUN_UID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
24
-
25
-
26
- class _Answer:
27
- def __init__(self, status_code=200, payload=None, headers=None):
28
- self.status_code = status_code
29
- self._payload = payload if payload is not None else {}
30
- self.headers = CaseInsensitiveDict(headers or {})
31
- self.closed = False
32
-
33
- def json(self):
34
- return self._payload
35
-
36
- def close(self):
37
- self.closed = True
38
-
39
-
40
- def _granted(access="runtime-access"):
41
- return _Answer(200, {"access": access, "token_type": "Bearer", "expires_in": 900})
42
-
43
-
44
- class _Exchange:
45
- """Answer exchange requests from a script, and record them and every wait."""
46
-
47
- def __init__(self, monkeypatch, *answers):
48
- self.answers = list(answers)
49
- self.requests = []
50
- self.sleeps = []
51
- monkeypatch.setattr(requests, "post", self._post)
52
- monkeypatch.setattr(time, "sleep", self.sleeps.append)
53
-
54
- def _post(self, url, **kwargs):
55
- self.requests.append({"url": url, **kwargs})
56
- if not self.answers:
57
- raise AssertionError("unexpected runtime credential exchange request")
58
- return self.answers.pop(0)
59
-
60
- @property
61
- def bodies(self):
62
- return [request["json"] for request in self.requests]
63
-
64
-
65
- @pytest.fixture
66
- def runtime(monkeypatch, tmp_path):
67
- for name in (
68
- "MAINSEQUENCE_ACCESS_TOKEN",
69
- "MAINSEQUENCE_REFRESH_TOKEN",
70
- "JOB_RUN_UID",
71
- SECRET_ENV,
72
- TOKEN_FILE_ENV,
73
- ):
74
- monkeypatch.delenv(name, raising=False)
75
- monkeypatch.setenv("MAINSEQUENCE_AUTH_MODE", "runtime_credential")
76
- monkeypatch.setenv("MAINSEQUENCE_ENDPOINT", "https://backend.example")
77
- monkeypatch.setenv(ID_ENV, "cred-id")
78
- return tmp_path
79
-
80
-
81
- def _use_token_file(monkeypatch, directory, token="projected-token-1"):
82
- path = directory / "token"
83
- path.write_text(token, encoding="utf-8")
84
- monkeypatch.setenv(TOKEN_FILE_ENV, str(path))
85
- return path
86
-
87
-
88
- @pytest.mark.parametrize("mode", ["identity_token", "bootstrap_secret"])
89
- def test_each_mode_sends_the_credential_id_and_exactly_one_proof(monkeypatch, runtime, mode):
90
- if mode == "identity_token":
91
- _use_token_file(monkeypatch, runtime)
92
- monkeypatch.setenv(SECRET_ENV, SECRET)
93
- proof = {"workload_identity_token": "projected-token-1"}
94
- else:
95
- monkeypatch.setenv(SECRET_ENV, "cred-secret")
96
- proof = {"credential_secret": "cred-secret"}
97
- utils = load_sdk_submodule("mainsequence.client.utils")
98
- exchange = _Exchange(monkeypatch, _granted())
99
-
100
- headers = utils.RuntimeCredentialAuthProvider().get_headers()
101
-
102
- assert headers["Authorization"] == "Bearer runtime-access"
103
- assert exchange.bodies == [{"credential_id": "cred-id", **proof}]
104
- assert exchange.requests[0]["url"] == TOKEN_URL
105
- assert exchange.requests[0]["headers"] == {"Content-Type": "application/json"}
106
- assert exchange.requests[0]["allow_redirects"] is False
107
- assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "runtime-access"
108
-
109
-
110
- def test_identity_token_mode_never_reads_the_secret(monkeypatch, runtime):
111
- _use_token_file(monkeypatch, runtime)
112
- monkeypatch.setenv(SECRET_ENV, SECRET)
113
- utils = load_sdk_submodule("mainsequence.client.utils")
114
- exchange = _Exchange(monkeypatch, _granted())
115
- read = []
116
- getenv = os.getenv
117
- monkeypatch.setattr(
118
- os, "getenv", lambda name, default=None: read.append(name) or getenv(name, default)
119
- )
120
-
121
- provider = utils.RuntimeCredentialAuthProvider()
122
- provider.refresh(force=True)
123
-
124
- assert TOKEN_FILE_ENV in read
125
- assert SECRET_ENV not in read
126
- assert provider.credential_secret is None
127
- assert SECRET not in repr(exchange.requests)
128
-
129
-
130
- def test_identity_token_file_is_read_for_every_exchange_and_not_at_start_up(monkeypatch, runtime):
131
- token_file = runtime / "token"
132
- monkeypatch.setenv(TOKEN_FILE_ENV, str(token_file))
133
- utils = load_sdk_submodule("mainsequence.client.utils")
134
- exchange = _Exchange(monkeypatch, _granted("access-1"), _granted("access-2"))
135
-
136
- provider = utils.RuntimeCredentialAuthProvider()
137
- token_file.write_text("projected-token-1", encoding="utf-8")
138
- provider.refresh(force=True)
139
- token_file.write_text("projected-token-2", encoding="utf-8")
140
- provider.refresh(force=True)
141
-
142
- assert [body["workload_identity_token"] for body in exchange.bodies] == [
143
- "projected-token-1",
144
- "projected-token-2",
145
- ]
146
- assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "access-2"
147
- assert "projected-token" not in repr(provider)
148
-
149
-
150
- @pytest.mark.parametrize(
151
- ("prepare", "reason"),
152
- [
153
- (lambda path: None, "does not exist"),
154
- (lambda path: path.write_text("", encoding="utf-8"), "is empty"),
155
- (lambda path: path.write_text(" \n", encoding="utf-8"), "is empty"),
156
- (lambda path: path.mkdir(), "could not be read (IsADirectoryError)"),
157
- (lambda path: path.write_bytes(b"\xff\xfeprojected"), "does not hold a token"),
158
- ],
159
- ids=["missing", "empty", "blank", "unreadable", "not-text"],
160
- )
161
- def test_unusable_identity_token_file_fails_without_falling_back_to_the_secret(
162
- monkeypatch, runtime, prepare, reason
163
- ):
164
- token_file = runtime / "token"
165
- prepare(token_file)
166
- monkeypatch.setenv(TOKEN_FILE_ENV, str(token_file))
167
- monkeypatch.setenv(SECRET_ENV, SECRET)
168
- utils = load_sdk_submodule("mainsequence.client.utils")
169
- exchange = _Exchange(monkeypatch)
170
-
171
- with pytest.raises(utils.AuthError) as raised:
172
- utils.RuntimeCredentialAuthProvider().get_headers()
173
-
174
- message = str(raised.value)
175
- assert reason in message
176
- assert str(token_file) in message
177
- assert TOKEN_FILE_ENV in message
178
- assert SECRET not in message
179
- assert exchange.requests == []
180
- chained = raised.value.__context__
181
- while chained is not None:
182
- assert not isinstance(chained, UnicodeDecodeError)
183
- chained = chained.__context__
184
-
185
-
186
- @pytest.mark.parametrize("mode", ["identity_token", "bootstrap_secret"])
187
- @pytest.mark.parametrize("status", [429, 503])
188
- def test_throttled_or_unavailable_exchange_is_retried_honoring_retry_after(
189
- monkeypatch, runtime, status, mode
190
- ):
191
- if mode == "identity_token":
192
- _use_token_file(monkeypatch, runtime)
193
- proof = {"workload_identity_token": "projected-token-1"}
194
- else:
195
- monkeypatch.setenv(SECRET_ENV, "cred-secret")
196
- proof = {"credential_secret": "cred-secret"}
197
- utils = load_sdk_submodule("mainsequence.client.utils")
198
- first = _Answer(status, {"detail": "Try again later."}, {"Retry-After": "7"})
199
- exchange = _Exchange(monkeypatch, first, _Answer(status), _granted())
200
-
201
- utils.RuntimeCredentialAuthProvider().refresh(force=True)
202
-
203
- # Retry-After (7 s) is longer than the first backoff (0.5 s); the second answer
204
- # has none, so the second backoff (1 s) applies.
205
- assert exchange.sleeps == [7, 1]
206
- assert exchange.bodies == [{"credential_id": "cred-id", **proof}] * 3
207
- assert first.closed is True
208
- assert os.environ["MAINSEQUENCE_ACCESS_TOKEN"] == "runtime-access"
209
-
210
-
211
- def test_exchange_retries_are_bounded(monkeypatch, runtime):
212
- _use_token_file(monkeypatch, runtime)
213
- utils = load_sdk_submodule("mainsequence.client.utils")
214
- exchange = _Exchange(
215
- monkeypatch, *(_Answer(503, headers={"Retry-After": "1"}) for _ in range(8))
216
- )
217
-
218
- with pytest.raises(utils.AuthError, match="failed with status 503"):
219
- utils.RuntimeCredentialAuthProvider().refresh(force=True)
220
-
221
- assert len(exchange.requests) == 4
222
- assert exchange.sleeps == [1, 1, 2]
223
-
224
-
225
- def test_each_attempt_receives_only_the_remaining_budget(monkeypatch, runtime):
226
- _use_token_file(monkeypatch, runtime)
227
- utils = load_sdk_submodule("mainsequence.client.utils")
228
- transport = importlib.import_module("mainsequence.client._transport")
229
- exchange = _Exchange(monkeypatch, _Answer(503, headers={"Retry-After": "4"}), _granted())
230
- now = [100.0]
231
-
232
- def _sleep(delay):
233
- exchange.sleeps.append(delay)
234
- now[0] += delay
235
-
236
- monkeypatch.setattr(time, "monotonic", lambda: now[0])
237
- monkeypatch.setattr(time, "sleep", _sleep)
238
-
239
- with transport.request_budget(10):
240
- utils.RuntimeCredentialAuthProvider().refresh(force=True)
241
-
242
- assert exchange.sleeps == [4]
243
- assert [request["timeout"].total for request in exchange.requests] == [10, 6]
244
-
245
-
246
- def test_retry_after_beyond_the_timeout_budget_ends_the_retries(monkeypatch, runtime):
247
- _use_token_file(monkeypatch, runtime)
248
- utils = load_sdk_submodule("mainsequence.client.utils")
249
- exchange = _Exchange(monkeypatch, _Answer(429, headers={"Retry-After": "3600"}))
250
-
251
- with pytest.raises(utils.AuthError, match="failed with status 429"):
252
- utils.RuntimeCredentialAuthProvider().refresh(force=True)
253
-
254
- assert len(exchange.requests) == 1
255
- assert exchange.sleeps == []
256
-
257
-
258
- @pytest.mark.parametrize("mode", ["identity_token", "bootstrap_secret"])
259
- def test_rejected_exchange_fails_at_once_without_switching_proof(monkeypatch, runtime, mode):
260
- if mode == "identity_token":
261
- _use_token_file(monkeypatch, runtime)
262
- proof = {"workload_identity_token": "projected-token-1"}
263
- else:
264
- proof = {"credential_secret": "cred-secret"}
265
- monkeypatch.setenv(SECRET_ENV, "cred-secret")
266
- utils = load_sdk_submodule("mainsequence.client.utils")
267
- rejected = _Answer(
268
- 401,
269
- {"detail": "Runtime credential exchange denied."},
270
- {"WWW-Authenticate": 'Bearer realm="runtime-credential-exchange"'},
271
- )
272
- exchange = _Exchange(monkeypatch, rejected)
273
-
274
- with pytest.raises(utils.AuthError, match="failed with status 401"):
275
- utils.RuntimeCredentialAuthProvider().refresh(force=True)
276
-
277
- assert exchange.bodies == [{"credential_id": "cred-id", **proof}]
278
- assert exchange.sleeps == []
279
-
280
-
281
- def test_identity_token_stays_out_of_logs_errors_and_the_environment(
282
- monkeypatch, runtime, capfd, caplog
283
- ):
284
- token = "projected-token-that-stays-in-the-exchange"
285
- _use_token_file(monkeypatch, runtime, token)
286
- utils = load_sdk_submodule("mainsequence.client.utils")
287
- logged = []
288
-
289
- class _Logger:
290
- def __getattr__(self, level):
291
- return lambda message, *args, **kwargs: logged.append(
292
- f"{level} {message} {args!r} {kwargs!r}"
293
- )
294
-
295
- monkeypatch.setattr(utils, "logger", _Logger())
296
- caplog.set_level(logging.DEBUG)
297
- exchange = _Exchange(
298
- monkeypatch,
299
- _Answer(503, {"detail": "Verification is unavailable."}, {"Retry-After": "1"}),
300
- _Answer(401, {"detail": "Runtime credential exchange denied."}),
301
- _Answer(401, {"detail": "Runtime credential exchange denied."}),
302
- )
303
-
304
- class _Session:
305
- headers = CaseInsensitiveDict()
306
-
307
- def get(self, url, **kwargs):
308
- raise AssertionError("the request must not be sent without an access token")
309
-
310
- loaders = utils.AuthLoaders()
311
- response = utils.make_request(_Session(), "GET", "https://backend.example/api/v1/x/", loaders)
312
- with pytest.raises(utils.AuthError) as raised:
313
- loaders.provider.refresh(force=True)
314
-
315
- assert response.status_code == 401
316
- assert [body["workload_identity_token"] for body in exchange.bodies] == [token] * 3
317
- assert any("retrying in 1.0 s" in line for line in logged)
318
- assert any("Auth error" in line for line in logged)
319
- captured = capfd.readouterr()
320
- for text in (
321
- *logged,
322
- caplog.text,
323
- captured.out,
324
- captured.err,
325
- response.content.decode("utf-8"),
326
- str(raised.value),
327
- repr(raised.value),
328
- repr(loaders.provider),
329
- repr(vars(loaders.provider)),
330
- ):
331
- assert token not in text
332
- assert all(token not in value for value in os.environ.values())
333
-
334
-
335
- def test_runtime_credential_is_configured_by_an_identity_token_file_or_a_secret(
336
- monkeypatch, runtime
337
- ):
338
- exchange = load_sdk_submodule("mainsequence.runtime_credential_exchange")
339
-
340
- assert exchange.runtime_credential_configured() is False
341
- monkeypatch.setenv(TOKEN_FILE_ENV, str(runtime / "token"))
342
- # Presence is enough: the exchange reads the file and reports a missing one.
343
- assert exchange.runtime_credential_configured() is True
344
- monkeypatch.delenv(TOKEN_FILE_ENV)
345
- monkeypatch.setenv(SECRET_ENV, "cred-secret")
346
- assert exchange.runtime_credential_configured() is True
347
- monkeypatch.delenv(ID_ENV)
348
- assert exchange.runtime_credential_configured() is False
349
-
350
-
351
- def test_logger_start_up_exchange_sends_the_identity_token_and_retries(monkeypatch, runtime):
352
- _use_token_file(monkeypatch, runtime)
353
- monkeypatch.setenv(SECRET_ENV, SECRET)
354
- monkeypatch.setenv("JOB_RUN_UID", JOB_RUN_UID)
355
- exchange = _Exchange(monkeypatch, _Answer(503, headers={"Retry-After": "1"}), _granted())
356
- authorizations = []
357
-
358
- def _get(url, **kwargs):
359
- authorizations.append(kwargs["headers"].get("Authorization"))
360
- return _Answer(200, {"job_run_uid": JOB_RUN_UID})
361
-
362
- monkeypatch.setattr(requests, "get", _get)
363
-
364
- load_sdk_submodule("mainsequence.logconf")
365
-
366
- proof = {"credential_id": "cred-id", "workload_identity_token": "projected-token-1"}
367
- assert exchange.bodies == [proof, proof]
368
- assert exchange.sleeps == [1]
369
- assert all(request["allow_redirects"] is False for request in exchange.requests)
370
- assert authorizations == ["Bearer runtime-access"]
371
-
372
-
373
- def test_logger_start_up_exchange_never_falls_back_to_the_secret(monkeypatch, runtime):
374
- monkeypatch.setenv(TOKEN_FILE_ENV, str(runtime / "missing-token"))
375
- monkeypatch.setenv(SECRET_ENV, SECRET)
376
- monkeypatch.setenv("JOB_RUN_UID", JOB_RUN_UID)
377
- exchange = _Exchange(monkeypatch)
378
- monkeypatch.setattr(requests, "get", lambda url, **kwargs: _Answer(401))
379
-
380
- load_sdk_submodule("mainsequence.logconf")
381
-
382
- assert exchange.requests == []
383
- assert "MAINSEQUENCE_ACCESS_TOKEN" not in os.environ
File without changes