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 +16 -0
- runacross/executor.py +185 -0
- runacross/models.py +152 -0
- runacross/organizations.py +122 -0
- runacross/py.typed +1 -0
- runacross/sts.py +136 -0
- runacross-0.1.0.dist-info/METADATA +290 -0
- runacross-0.1.0.dist-info/RECORD +10 -0
- runacross-0.1.0.dist-info/WHEEL +4 -0
- runacross-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
[](https://github.com/antoniomml/runacross/actions/workflows/ci.yml)
|
|
38
|
+
[](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,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
|
+
|