runacross 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
runacross/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """RunAcross public API."""
2
+
3
+ import logging
4
+
5
+ from .executor import map_accounts
6
+ from .models import Account, AccountResult, ExecutionPhase, RunResults
7
+
8
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
9
+
10
+ __all__ = [
11
+ "Account",
12
+ "AccountResult",
13
+ "ExecutionPhase",
14
+ "RunResults",
15
+ "map_accounts",
16
+ ]
runacross/executor.py ADDED
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import traceback
5
+ from collections.abc import Callable, Iterable
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from time import perf_counter
8
+ from typing import TypeVar, cast
9
+
10
+ import boto3
11
+ from boto3.session import Session
12
+ from botocore.config import Config
13
+
14
+ from .models import (
15
+ Account,
16
+ AccountInput,
17
+ AccountResult,
18
+ ExecutionPhase,
19
+ RunResults,
20
+ coerce_accounts,
21
+ )
22
+ from .sts import (
23
+ StsClient,
24
+ assume_role_session,
25
+ build_client_config,
26
+ validate_assume_role_options,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ T = TypeVar("T")
32
+
33
+
34
+ def map_accounts(
35
+ function: Callable[[Session, Account], T],
36
+ *,
37
+ accounts: Iterable[AccountInput],
38
+ role_name: str,
39
+ role_session_name: str = "runacross",
40
+ external_id: str | None = None,
41
+ source_session: Session | None = None,
42
+ botocore_config: Config | None = None,
43
+ max_workers: int = 10,
44
+ ) -> RunResults[T]:
45
+ """Execute a callback concurrently in multiple AWS accounts."""
46
+
47
+ if not callable(function):
48
+ raise TypeError("function must be callable")
49
+ if isinstance(max_workers, bool) or not isinstance(max_workers, int):
50
+ raise TypeError("max_workers must be an integer")
51
+ if max_workers < 1:
52
+ raise ValueError("max_workers must be at least 1")
53
+
54
+ validate_assume_role_options(
55
+ role_name=role_name,
56
+ role_session_name=role_session_name,
57
+ external_id=external_id,
58
+ )
59
+ target_accounts = coerce_accounts(accounts)
60
+ if not target_accounts:
61
+ return RunResults()
62
+
63
+ session = source_session if source_session is not None else boto3.Session()
64
+ client_config = build_client_config(
65
+ max_pool_connections=max_workers,
66
+ user_config=botocore_config,
67
+ )
68
+ sts_client = cast(
69
+ StsClient,
70
+ session.client("sts", config=client_config),
71
+ )
72
+ source_region = session.region_name
73
+
74
+ ordered: list[AccountResult[T] | None] = [None] * len(target_accounts)
75
+ with ThreadPoolExecutor(
76
+ max_workers=max_workers,
77
+ thread_name_prefix="runacross",
78
+ ) as executor:
79
+ future_indexes = {
80
+ executor.submit(
81
+ _execute_account,
82
+ function,
83
+ account,
84
+ sts_client=sts_client,
85
+ role_name=role_name,
86
+ role_session_name=role_session_name,
87
+ external_id=external_id,
88
+ region_name=source_region,
89
+ ): index
90
+ for index, account in enumerate(target_accounts)
91
+ }
92
+
93
+ for future in as_completed(future_indexes):
94
+ ordered[future_indexes[future]] = future.result()
95
+
96
+ return RunResults(cast(list[AccountResult[T]], ordered))
97
+
98
+
99
+ def _execute_account(
100
+ function: Callable[[Session, Account], T],
101
+ account: Account,
102
+ *,
103
+ sts_client: StsClient,
104
+ role_name: str,
105
+ role_session_name: str,
106
+ external_id: str | None,
107
+ region_name: str | None,
108
+ ) -> AccountResult[T]:
109
+ started_at = perf_counter()
110
+ logger.debug("Assuming role into account %s", account.id)
111
+
112
+ try:
113
+ target_session = assume_role_session(
114
+ sts_client,
115
+ account,
116
+ role_name=role_name,
117
+ role_session_name=role_session_name,
118
+ external_id=external_id,
119
+ region_name=region_name,
120
+ )
121
+ except Exception as error:
122
+ duration = perf_counter() - started_at
123
+ logger.debug(
124
+ "AssumeRole failed for account %s after %.3fs",
125
+ account.id,
126
+ duration,
127
+ exc_info=True,
128
+ )
129
+ _clear_exception_tracebacks(error)
130
+ return AccountResult(
131
+ account=account,
132
+ value=None,
133
+ error=error,
134
+ duration_seconds=duration,
135
+ phase=ExecutionPhase.ASSUME_ROLE,
136
+ )
137
+
138
+ logger.debug("Starting worker for account %s", account.id)
139
+ try:
140
+ value = function(target_session, account)
141
+ except Exception as error:
142
+ duration = perf_counter() - started_at
143
+ logger.debug(
144
+ "Worker failed for account %s after %.3fs",
145
+ account.id,
146
+ duration,
147
+ exc_info=True,
148
+ )
149
+ _clear_exception_tracebacks(error)
150
+ return AccountResult(
151
+ account=account,
152
+ value=None,
153
+ error=error,
154
+ duration_seconds=duration,
155
+ phase=ExecutionPhase.WORKER,
156
+ )
157
+
158
+ duration = perf_counter() - started_at
159
+ logger.debug("Completed account %s in %.3fs", account.id, duration)
160
+ return AccountResult(
161
+ account=account,
162
+ value=value,
163
+ error=None,
164
+ duration_seconds=duration,
165
+ phase=None,
166
+ )
167
+
168
+
169
+ def _clear_exception_tracebacks(error: BaseException) -> None:
170
+ pending: list[BaseException] = [error]
171
+ seen: set[int] = set()
172
+
173
+ while pending:
174
+ current = pending.pop()
175
+ if id(current) in seen:
176
+ continue
177
+ seen.add(id(current))
178
+
179
+ if current.__traceback__ is not None:
180
+ traceback.clear_frames(current.__traceback__)
181
+ current.__traceback__ = None
182
+ if current.__cause__ is not None:
183
+ pending.append(current.__cause__)
184
+ if current.__context__ is not None:
185
+ pending.append(current.__context__)
runacross/models.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable, Iterator, Sequence
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Generic, TypeVar, cast, overload
8
+
9
+ _ACCOUNT_ID_PATTERN = re.compile(r"[0-9]{12}\Z")
10
+
11
+ T_co = TypeVar("T_co", covariant=True)
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Account:
16
+ """An AWS account targeted by an execution."""
17
+
18
+ id: str
19
+ name: str | None = None
20
+ email: str | None = None
21
+
22
+ def __post_init__(self) -> None:
23
+ if not isinstance(self.id, str):
24
+ raise TypeError("account id must be a string")
25
+ if _ACCOUNT_ID_PATTERN.fullmatch(self.id) is None:
26
+ raise ValueError("account id must contain exactly 12 ASCII digits")
27
+ if self.name is not None and not isinstance(self.name, str):
28
+ raise TypeError("account name must be a string or None")
29
+ if self.email is not None and not isinstance(self.email, str):
30
+ raise TypeError("account email must be a string or None")
31
+
32
+
33
+ AccountInput = str | Account
34
+
35
+
36
+ def coerce_accounts(accounts: Iterable[AccountInput]) -> tuple[Account, ...]:
37
+ """Convert account IDs and Account instances into validated Accounts."""
38
+
39
+ if isinstance(accounts, (str, bytes)):
40
+ raise TypeError(
41
+ "accounts must be an iterable of account IDs or Account objects"
42
+ )
43
+
44
+ coerced: list[Account] = []
45
+ for index, account in enumerate(accounts):
46
+ if isinstance(account, Account):
47
+ coerced.append(account)
48
+ elif isinstance(account, str):
49
+ coerced.append(Account(id=account))
50
+ else:
51
+ raise TypeError(
52
+ f"account at index {index} must be an account ID or Account object"
53
+ )
54
+ return tuple(coerced)
55
+
56
+
57
+ class ExecutionPhase(str, Enum):
58
+ """The execution phase in which an account failed."""
59
+
60
+ ASSUME_ROLE = "assume_role"
61
+ WORKER = "worker"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class AccountResult(Generic[T_co]):
66
+ """The outcome of executing a callback for one AWS account."""
67
+
68
+ account: Account
69
+ value: T_co | None
70
+ error: Exception | None
71
+ duration_seconds: float
72
+ phase: ExecutionPhase | None
73
+
74
+ def __post_init__(self) -> None:
75
+ if self.duration_seconds < 0:
76
+ raise ValueError("duration_seconds cannot be negative")
77
+ if self.error is None and self.phase is not None:
78
+ raise ValueError("a successful result cannot have a failure phase")
79
+ if self.error is not None and self.phase is None:
80
+ raise ValueError("a failed result must have a failure phase")
81
+ if self.error is not None and self.value is not None:
82
+ raise ValueError("a failed result cannot also contain a value")
83
+
84
+ @property
85
+ def success(self) -> bool:
86
+ """Whether the callback completed successfully."""
87
+
88
+ return self.error is None
89
+
90
+ def unwrap(self) -> T_co:
91
+ """Return the value or raise the account's stored exception."""
92
+
93
+ if self.error is not None:
94
+ raise self.error
95
+ return cast(T_co, self.value)
96
+
97
+
98
+ class RunResults(Sequence[AccountResult[T_co]], Generic[T_co]):
99
+ """An immutable, ordered collection of per-account results."""
100
+
101
+ __slots__ = ("_results",)
102
+
103
+ def __init__(self, results: Iterable[AccountResult[T_co]] = ()) -> None:
104
+ self._results = tuple(results)
105
+
106
+ @overload
107
+ def __getitem__(self, index: int) -> AccountResult[T_co]: ...
108
+
109
+ @overload
110
+ def __getitem__(self, index: slice) -> tuple[AccountResult[T_co], ...]: ...
111
+
112
+ def __getitem__(
113
+ self, index: int | slice
114
+ ) -> AccountResult[T_co] | tuple[AccountResult[T_co], ...]:
115
+ return self._results[index]
116
+
117
+ def __iter__(self) -> Iterator[AccountResult[T_co]]:
118
+ return iter(self._results)
119
+
120
+ def __len__(self) -> int:
121
+ return len(self._results)
122
+
123
+ def __repr__(self) -> str:
124
+ return (
125
+ f"{type(self).__name__}("
126
+ f"success_count={self.success_count}, "
127
+ f"failure_count={self.failure_count})"
128
+ )
129
+
130
+ @property
131
+ def successful(self) -> tuple[AccountResult[T_co], ...]:
132
+ """Successful results in input order."""
133
+
134
+ return tuple(result for result in self._results if result.success)
135
+
136
+ @property
137
+ def failed(self) -> tuple[AccountResult[T_co], ...]:
138
+ """Failed results in input order."""
139
+
140
+ return tuple(result for result in self._results if not result.success)
141
+
142
+ @property
143
+ def success_count(self) -> int:
144
+ """Number of successful results."""
145
+
146
+ return sum(result.success for result in self._results)
147
+
148
+ @property
149
+ def failure_count(self) -> int:
150
+ """Number of failed results."""
151
+
152
+ return len(self) - self.success_count
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from collections.abc import Iterable
6
+ from typing import Any, Protocol, cast
7
+
8
+ import boto3
9
+ from boto3.session import Session
10
+ from botocore.config import Config
11
+
12
+ from .models import Account, AccountInput, coerce_accounts
13
+ from .sts import build_client_config
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _ORGANIZATION_ID_PATTERN = re.compile(r"o-[a-z0-9]{10,32}\Z")
18
+
19
+
20
+ class _Paginator(Protocol):
21
+ def paginate(self) -> Iterable[dict[str, Any]]:
22
+ """Return all Organizations response pages."""
23
+
24
+
25
+ class _OrganizationsClient(Protocol):
26
+ def describe_organization(self) -> dict[str, Any]:
27
+ """Describe the caller's AWS Organization."""
28
+
29
+ def get_paginator(self, operation_name: str) -> _Paginator:
30
+ """Create a paginator for an Organizations operation."""
31
+
32
+
33
+ def list_accounts(
34
+ *,
35
+ organization_id: str | None = None,
36
+ session: Session | None = None,
37
+ botocore_config: Config | None = None,
38
+ exclude_accounts: Iterable[AccountInput] = (),
39
+ ) -> list[Account]:
40
+ """List active accounts from the caller's AWS Organization."""
41
+
42
+ _validate_organization_id(organization_id)
43
+ excluded_ids = {account.id for account in coerce_accounts(exclude_accounts)}
44
+
45
+ source_session = session if session is not None else boto3.Session()
46
+ client = cast(
47
+ _OrganizationsClient,
48
+ source_session.client(
49
+ "organizations",
50
+ config=build_client_config(
51
+ max_pool_connections=10,
52
+ user_config=botocore_config,
53
+ ),
54
+ ),
55
+ )
56
+
57
+ if organization_id is not None:
58
+ actual_id = _get_organization_id(client)
59
+ if actual_id != organization_id:
60
+ raise ValueError(
61
+ "organization_id does not match the organization available "
62
+ f"to the source credentials: expected {organization_id}, "
63
+ f"got {actual_id}"
64
+ )
65
+
66
+ accounts: list[Account] = []
67
+ paginator = client.get_paginator("list_accounts")
68
+ for page in paginator.paginate():
69
+ for item in page.get("Accounts", []):
70
+ state = item.get("State")
71
+ if state is None:
72
+ raise RuntimeError(
73
+ "AWS Organizations did not return Account.State; "
74
+ "use a Boto3 version released after September 9, 2025"
75
+ )
76
+ if state != "ACTIVE":
77
+ continue
78
+
79
+ account_id = item.get("Id")
80
+ if account_id is None:
81
+ raise RuntimeError(
82
+ "AWS Organizations returned an account without an Id"
83
+ )
84
+ if account_id in excluded_ids:
85
+ logger.debug("Excluded organization account %s", account_id)
86
+ continue
87
+
88
+ accounts.append(
89
+ Account(
90
+ id=account_id,
91
+ name=item.get("Name"),
92
+ email=item.get("Email"),
93
+ )
94
+ )
95
+
96
+ logger.debug("Discovered %d active organization accounts", len(accounts))
97
+ return accounts
98
+
99
+
100
+ def _validate_organization_id(organization_id: str | None) -> None:
101
+ if organization_id is None:
102
+ return
103
+ if not isinstance(organization_id, str):
104
+ raise TypeError("organization_id must be a string or None")
105
+ if _ORGANIZATION_ID_PATTERN.fullmatch(organization_id) is None:
106
+ raise ValueError(
107
+ "organization_id must start with 'o-' followed by "
108
+ "10-32 lowercase letters or digits"
109
+ )
110
+
111
+
112
+ def _get_organization_id(client: _OrganizationsClient) -> str:
113
+ response = client.describe_organization()
114
+ try:
115
+ organization_id = response["Organization"]["Id"]
116
+ except (KeyError, TypeError) as error:
117
+ raise RuntimeError(
118
+ "AWS Organizations returned a response without Organization.Id"
119
+ ) from error
120
+ if not isinstance(organization_id, str):
121
+ raise RuntimeError("AWS Organizations returned a non-string Organization.Id")
122
+ return organization_id
runacross/py.typed ADDED
@@ -0,0 +1 @@
1
+
runacross/sts.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Protocol, cast
5
+
6
+ import boto3
7
+ from boto3.session import Session
8
+ from botocore.config import Config
9
+
10
+ from .models import Account
11
+
12
+ _ROLE_SESSION_NAME_PATTERN = re.compile(r"[A-Za-z0-9_+=,.@-]{2,64}\Z")
13
+ _EXTERNAL_ID_PATTERN = re.compile(r"[A-Za-z0-9_+=,.@:/-]{2,1224}\Z")
14
+ _ROLE_NAME_PATTERN = re.compile(r"[A-Za-z0-9_+=,.@-]{1,64}\Z")
15
+ _ROLE_PATH_PATTERN = re.compile(r"[\x21-\x7e]*\Z")
16
+
17
+
18
+ class _ClientMeta(Protocol):
19
+ partition: str
20
+
21
+
22
+ class StsClient(Protocol):
23
+ """The subset of an STS client used by RunAcross."""
24
+
25
+ meta: _ClientMeta
26
+
27
+ def assume_role(self, **kwargs: Any) -> dict[str, Any]:
28
+ """Call STS AssumeRole."""
29
+
30
+
31
+ def build_client_config(
32
+ *,
33
+ max_pool_connections: int,
34
+ user_config: Config | None,
35
+ ) -> Config:
36
+ """Create the Botocore configuration for RunAcross-owned clients."""
37
+
38
+ defaults = Config(
39
+ retries={
40
+ "mode": "standard",
41
+ "total_max_attempts": 3,
42
+ },
43
+ max_pool_connections=max(10, max_pool_connections),
44
+ )
45
+ return defaults.merge(user_config) if user_config is not None else defaults
46
+
47
+
48
+ def validate_assume_role_options(
49
+ *,
50
+ role_name: str,
51
+ role_session_name: str,
52
+ external_id: str | None,
53
+ ) -> None:
54
+ """Validate global AssumeRole options before concurrent work starts."""
55
+
56
+ if not isinstance(role_name, str):
57
+ raise TypeError("role_name must be a string")
58
+ if (
59
+ not role_name
60
+ or role_name.startswith("/")
61
+ or role_name.endswith("/")
62
+ or "//" in role_name
63
+ ):
64
+ raise ValueError("role_name must be a non-empty IAM role name or path")
65
+
66
+ *path_parts, final_role_name = role_name.split("/")
67
+ role_path = "/".join(path_parts)
68
+ iam_path = f"/{role_path}/" if role_path else "/"
69
+ if (
70
+ _ROLE_NAME_PATTERN.fullmatch(final_role_name) is None
71
+ or _ROLE_PATH_PATTERN.fullmatch(role_path) is None
72
+ or len(iam_path) > 512
73
+ ):
74
+ raise ValueError(
75
+ "role_name must contain a valid IAM path and a 1-64 character "
76
+ "role name using letters, digits, or _+=,.@-"
77
+ )
78
+
79
+ if not isinstance(role_session_name, str):
80
+ raise TypeError("role_session_name must be a string")
81
+ if _ROLE_SESSION_NAME_PATTERN.fullmatch(role_session_name) is None:
82
+ raise ValueError(
83
+ "role_session_name must be 2-64 characters using "
84
+ "letters, digits, or _+=,.@-"
85
+ )
86
+
87
+ if external_id is not None:
88
+ if not isinstance(external_id, str):
89
+ raise TypeError("external_id must be a string or None")
90
+ if _EXTERNAL_ID_PATTERN.fullmatch(external_id) is None:
91
+ raise ValueError(
92
+ "external_id must be 2-1224 characters using "
93
+ "letters, digits, or _+=,.@:/-"
94
+ )
95
+
96
+
97
+ def build_role_arn(account: Account, role_name: str, partition: str) -> str:
98
+ """Build an IAM role ARN for an account and AWS partition."""
99
+
100
+ return f"arn:{partition}:iam::{account.id}:role/{role_name}"
101
+
102
+
103
+ def assume_role_session(
104
+ sts_client: StsClient,
105
+ account: Account,
106
+ *,
107
+ role_name: str,
108
+ role_session_name: str,
109
+ external_id: str | None,
110
+ region_name: str | None,
111
+ ) -> Session:
112
+ """Assume a role and create a new Session for one account."""
113
+
114
+ request: dict[str, Any] = {
115
+ "RoleArn": build_role_arn(
116
+ account,
117
+ role_name,
118
+ sts_client.meta.partition,
119
+ ),
120
+ "RoleSessionName": role_session_name,
121
+ }
122
+ if external_id is not None:
123
+ request["ExternalId"] = external_id
124
+
125
+ response = sts_client.assume_role(**request)
126
+ credentials = response["Credentials"]
127
+
128
+ return cast(
129
+ Session,
130
+ boto3.Session(
131
+ aws_access_key_id=credentials["AccessKeyId"],
132
+ aws_secret_access_key=credentials["SecretAccessKey"],
133
+ aws_session_token=credentials["SessionToken"],
134
+ region_name=region_name,
135
+ ),
136
+ )
@@ -0,0 +1,290 @@
1
+ Metadata-Version: 2.5
2
+ Name: runacross
3
+ Version: 0.1.0
4
+ Summary: Concurrent Python execution across AWS accounts.
5
+ Project-URL: Homepage, https://github.com/antoniomml/runacross
6
+ Project-URL: Repository, https://github.com/antoniomml/runacross
7
+ Project-URL: Issues, https://github.com/antoniomml/runacross/issues
8
+ Project-URL: Changelog, https://github.com/antoniomml/runacross/blob/main/CHANGELOG.md
9
+ Author: Antonio Milla
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: aws,boto3,multi-account,organizations,sts
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: boto3>=1.40.40
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == 'dev'
29
+ Requires-Dist: mypy>=1.17; extra == 'dev'
30
+ Requires-Dist: pytest>=8.4; extra == 'dev'
31
+ Requires-Dist: ruff>=0.12; extra == 'dev'
32
+ Requires-Dist: twine>=6; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # RunAcross
36
+
37
+ [![CI](https://github.com/antoniomml/runacross/actions/workflows/ci.yml/badge.svg)](https://github.com/antoniomml/runacross/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/runacross.svg)](https://pypi.org/project/runacross/)
39
+
40
+ Concurrent Python execution across AWS accounts.
41
+
42
+ RunAcross handles STS AssumeRole, concurrent execution, error isolation and
43
+ result aggregation so your code can focus on the AWS operation itself.
44
+
45
+ > RunAcross 0.1 executes across accounts. Account-by-Region execution is
46
+ > planned for a later release.
47
+
48
+ ## Why RunAcross?
49
+
50
+ Multi-account scripts repeatedly need the same plumbing:
51
+
52
+ ```text
53
+ accounts
54
+ -> STS AssumeRole
55
+ -> boto3 Session
56
+ -> ThreadPoolExecutor
57
+ -> callback
58
+ -> isolated errors
59
+ -> aggregated results
60
+ ```
61
+
62
+ RunAcross packages that pattern as a small synchronous function. It is a
63
+ library primitive, not a scanner, CLI, scheduler, or credentials manager.
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ pip install runacross
69
+ ```
70
+
71
+ RunAcross requires Python 3.10 or later. For development from a local clone:
72
+
73
+ ```bash
74
+ python -m pip install -e ".[dev]"
75
+ ```
76
+
77
+ ## Quickstart
78
+
79
+ ```python
80
+ from runacross import map_accounts
81
+
82
+
83
+ def who_am_i(session, account):
84
+ sts = session.client("sts")
85
+ return sts.get_caller_identity()["Arn"]
86
+
87
+
88
+ results = map_accounts(
89
+ who_am_i,
90
+ accounts=[
91
+ "111111111111",
92
+ "222222222222",
93
+ ],
94
+ role_name="SecurityAuditRole",
95
+ max_workers=10,
96
+ )
97
+
98
+ for result in results:
99
+ if result.success:
100
+ print(f"{result.account.id}: {result.value}")
101
+ else:
102
+ print(f"{result.account.id}: {result.phase}: {result.error}")
103
+ ```
104
+
105
+ Account IDs are converted to immutable `Account` objects. You can also provide
106
+ metadata explicitly:
107
+
108
+ ```python
109
+ from runacross import Account
110
+
111
+ accounts = [
112
+ Account(id="111111111111", name="Production"),
113
+ Account(id="222222222222", name="Development"),
114
+ ]
115
+ ```
116
+
117
+ ## Using AWS Organizations
118
+
119
+ Organizations is an optional, explicit source of accounts:
120
+
121
+ ```python
122
+ from runacross import map_accounts
123
+ from runacross.organizations import list_accounts
124
+
125
+ accounts = list_accounts(
126
+ organization_id="o-exampleorgid",
127
+ exclude_accounts=["111111111111"],
128
+ )
129
+
130
+ results = map_accounts(
131
+ who_am_i,
132
+ accounts=accounts,
133
+ role_name="SecurityAuditRole",
134
+ )
135
+ ```
136
+
137
+ The organization ID is a safety check, not a selector. AWS uses the source
138
+ credentials to determine which organization is visible. RunAcross verifies
139
+ that it matches the expected ID and then returns accounts whose current
140
+ Organizations `State` is `ACTIVE`.
141
+
142
+ Call `list_accounts()` without an ID when that guard is not needed.
143
+ Discovered `Account` objects include the Organizations name and root email
144
+ address; treat those fields as sensitive.
145
+
146
+ ## Handling failures
147
+
148
+ One failed account does not cancel the others:
149
+
150
+ ```python
151
+ for result in results:
152
+ if result.success:
153
+ use(result.value)
154
+ elif result.phase == "assume_role":
155
+ report_access_problem(result.account, result.error)
156
+ else:
157
+ report_worker_problem(result.account, result.error)
158
+ ```
159
+
160
+ `RunResults` preserves input order and provides:
161
+
162
+ ```python
163
+ results.successful
164
+ results.failed
165
+ results.success_count
166
+ results.failure_count
167
+ ```
168
+
169
+ Use `result.unwrap()` when code wants the typed value or the stored exception:
170
+
171
+ ```python
172
+ for result in results:
173
+ try:
174
+ value = result.unwrap()
175
+ except Exception as error:
176
+ handle(result.account, error)
177
+ ```
178
+
179
+ RunAcross does not retry the callback because arbitrary functions may not be
180
+ idempotent.
181
+
182
+ ## Concurrency
183
+
184
+ RunAcross uses `ThreadPoolExecutor`, which is suitable for the network-bound
185
+ work performed by Boto3. The default is 10 workers:
186
+
187
+ ```python
188
+ results = map_accounts(
189
+ who_am_i,
190
+ accounts=accounts,
191
+ role_name="SecurityAuditRole",
192
+ max_workers=5,
193
+ )
194
+ ```
195
+
196
+ More workers do not imply linear speedups. High concurrency can increase
197
+ throttling, connection use, memory use, and Lambda duration.
198
+
199
+ RunAcross-owned clients use Botocore standard retries with three total
200
+ attempts. Override them explicitly when needed:
201
+
202
+ ```python
203
+ from botocore.config import Config
204
+
205
+ results = map_accounts(
206
+ who_am_i,
207
+ accounts=accounts,
208
+ role_name="SecurityAuditRole",
209
+ botocore_config=Config(
210
+ retries={"mode": "adaptive", "total_max_attempts": 5},
211
+ ),
212
+ )
213
+ ```
214
+
215
+ This configuration applies only to clients RunAcross creates, such as STS.
216
+ Pass a `Config` to clients created inside your callback to configure their
217
+ retries.
218
+
219
+ ## Authentication
220
+
221
+ By default, RunAcross uses `boto3.Session()` and the standard Boto3 credential
222
+ provider chain. It works with configured environment credentials, profiles,
223
+ IAM Identity Center, web identity, ECS task roles, EC2 instance profiles,
224
+ Lambda execution roles, and GitHub Actions OIDC.
225
+
226
+ You can provide a source Session:
227
+
228
+ ```python
229
+ import boto3
230
+
231
+ source_session = boto3.Session(profile_name="security")
232
+
233
+ results = map_accounts(
234
+ who_am_i,
235
+ accounts=accounts,
236
+ role_name="SecurityAuditRole",
237
+ source_session=source_session,
238
+ )
239
+ ```
240
+
241
+ The target role must trust the source identity, and the source identity must
242
+ be allowed to call `sts:AssumeRole`.
243
+
244
+ Assumed Sessions in 0.1 do not refresh automatically. Callbacks should finish
245
+ within the STS session lifetime, normally one hour.
246
+
247
+ ## IAM permissions
248
+
249
+ The source identity needs `sts:AssumeRole` for the target roles. Organizations
250
+ discovery additionally needs `organizations:ListAccounts`; using the
251
+ organization ID guard also needs `organizations:DescribeOrganization`.
252
+
253
+ The assumed role needs only the service permissions used by the callback.
254
+ See [docs/iam.md](docs/iam.md) for restrictive examples and trust-policy
255
+ requirements.
256
+
257
+ ## Running in AWS Lambda
258
+
259
+ Lambda execution-role credentials are discovered automatically. Package
260
+ RunAcross and its Boto3 dependency with the function or in a layer so the
261
+ versions are controlled by your deployment.
262
+
263
+ All callbacks must finish before the handler returns. Do not leave RunAcross
264
+ work running in the background between Lambda invocations. Tune `max_workers`
265
+ for the function's memory, timeout, and downstream AWS quotas.
266
+
267
+ ## Security
268
+
269
+ RunAcross does not persist or return STS credentials, add telemetry, or create
270
+ non-AWS service clients. Library logging is silent unless the application
271
+ configures it. RunAcross never deliberately adds credentials to logs, but
272
+ callback exception messages are emitted at DEBUG and must not contain secrets.
273
+
274
+ See [SECURITY.md](SECURITY.md) for vulnerability reporting.
275
+
276
+ ## Roadmap
277
+
278
+ Planned areas include explicit account-by-Region execution, enabled-Region
279
+ discovery, and small Organizations filters. RunAcross will remain a library
280
+ primitive rather than becoming an orchestration framework.
281
+
282
+ See [docs/roadmap.md](docs/roadmap.md).
283
+
284
+ ## Contributing
285
+
286
+ Development uses pytest, Ruff, and mypy. See
287
+ [CONTRIBUTING.md](CONTRIBUTING.md).
288
+
289
+ RunAcross is licensed under the Apache License 2.0.
290
+
@@ -0,0 +1,10 @@
1
+ runacross/__init__.py,sha256=6z0in8QqqmtiEEnBLxhtKOABRlmWccJHIXMzeCdA8GU,325
2
+ runacross/executor.py,sha256=h3HtGTSlRINhgBXlreNSBlMtwBBnpHaEPG_wYkLtqBY,5338
3
+ runacross/models.py,sha256=HK3Z8e2chrihs2o2n22XJ9n4ZT4jzx2_C_5pz4ezVzs,4760
4
+ runacross/organizations.py,sha256=myuC_Na9EXzH8ywC2Isd5_j87fsmLIikr9YfKPSAyz0,4053
5
+ runacross/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ runacross/sts.py,sha256=ZCHIQe1Y1hG8vX0WmSPe0QUSXOuP7iCmCSGmRwYlg7A,4009
7
+ runacross-0.1.0.dist-info/METADATA,sha256=i3jpbpxZ0DE8fM6aZ4bRt7d3w_PYY6WbdH2fwcSGsMU,8318
8
+ runacross-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ runacross-0.1.0.dist-info/licenses/LICENSE,sha256=kcVmHWUWnc6ORsSzvPdvbUvjzhfEbN7ly6lThdUsAvQ,11357
10
+ runacross-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,202 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+