RunAcross
Concurrent Python execution across AWS accounts and Regions.
RunAcross authenticates into each account, runs your callback concurrently, isolates errors, and aggregates results so your code can focus on the AWS operation itself. Authentication is pluggable: assume an IAM role in every account, or use named AWS CLI / IAM Identity Center profiles.
Why RunAcross?
Multi-account scripts repeatedly need the same plumbing:
accounts
-> Role or Profile
-> boto3 Session
-> ThreadPoolExecutor
-> callback
-> isolated errors
-> aggregated results
RunAcross packages that pattern as small synchronous functions. It is a library primitive, not a scanner, CLI, scheduler, or credentials manager.
Installation
pip install runacross
RunAcross requires Python 3.10 or later. For development from a local clone:
python -m pip install -e ".[dev]"
Quickstart
Write a function for one account. RunAcross runs it in many:
from runacross import map_accounts
def who_am_i(session, account):
sts = session.client("sts")
return sts.get_caller_identity()["Arn"]
results = map_accounts(
who_am_i,
accounts=["111111111111", "222222222222"],
role_name="SecurityAuditRole",
)
for result in results:
if result.success:
print(f"{result.account.id}: {result.value}")
else:
print(f"{result.account.id}: {result.phase}: {result.error}")
role_name= is a shortcut for auth=Role("SecurityAuditRole"). Account IDs
are converted to immutable Account objects. You can also provide metadata
explicitly:
from runacross import Account
accounts = [
Account(id="111111111111", name="Production"),
Account(id="222222222222", name="Development"),
]
Authentication: Role and Profile
The callback never sees how the Session was obtained. Choose one strategy per
map_accounts / map_account_regions call.
Role
Assume the same IAM role in each target account. This is the Organizations / Lambda / CI pattern:
import boto3
from runacross import Role, map_accounts
results = map_accounts(
who_am_i,
accounts=accounts,
auth=Role(
"SecurityAuditRole",
source_session=boto3.Session(profile_name="security"),
duration_seconds=900,
),
)
The source identity must be allowed to call sts:AssumeRole, and each target
role must trust that identity. role_name= on map_accounts still builds a
Role for compatibility with 0.1.
Profile
Use named profiles from ~/.aws/config, including IAM Identity Center
profiles. This is the laptop pattern after aws sso login:
from runacross import Profile, map_accounts
results = map_accounts(
who_am_i,
accounts=["111111111111", "222222222222"],
auth=Profile("{account_id}-script-SecurityAudit"),
)
If your profiles are named 111111111111-script-SecurityAudit, that pattern
is enough. When names do not follow a convention, pass an explicit mapping or
a resolver:
Profile(
mapping={
"111111111111": "prod-security",
"222222222222": "dev-readonly",
}
)
Profile(resolver=lambda account: f"sso-{account.id}")
Exactly one of pattern, mapping, or resolver is required. Pattern
placeholders are {account_id} and, if present on the Account, {name}.
Profile does not discover accounts automatically, RunAcross does not run
aws sso login, and one execution cannot mix Role and Profile. Configure the
strategy in your application:
import os
from runacross import Profile, Role
def auth():
if os.environ.get("RUNACROSS_ROLE_NAME"):
return Role(os.environ["RUNACROSS_ROLE_NAME"])
return Profile(os.environ["RUNACROSS_PROFILE_PATTERN"])
Account sources
Discovery and execution stay as two steps, so you can inspect or filter accounts before any callback runs:
accounts = list_accounts(...)
results = map_accounts(callback, accounts=accounts, auth=...)
runacross.profiles.list_accounts() |
runacross.organizations.list_accounts() |
|
|---|---|---|
| Reads | Local ~/.aws/config (including AWS_CONFIG_FILE) |
AWS Organizations ListAccounts |
| Network | No, unless the optional organization guard is used | Yes |
| Returns | Profiles attached to one named sso_session |
Accounts whose current State is ACTIVE |
| Freshness | Configured targets; can include closed accounts or stale assignments | Live organization inventory |
| Permissions | None locally; organizations:DescribeOrganization if you pass the guard |
organizations:ListAccounts; also DescribeOrganization with the ID guard |
You can also pass account IDs or Account objects from a file, API, or your
own inventory. See examples/README.md for short scripts.
Identity Center profiles
When the shared AWS config is the source of the account list, discover only profiles attached to one named SSO session:
from runacross import Profile, map_accounts
from runacross.profiles import list_accounts
accounts = list_accounts(
pattern="AWS-Infosec-{account_id}",
sso_session="control-tower",
limit=3,
)
results = map_accounts(
who_am_i,
accounts=accounts,
auth=Profile("AWS-Infosec-{account_id}"),
)
sso_session is required so profiles from different Identity Center sessions
cannot be combined accidentally. The profile name must match the pattern
exactly, and its captured account ID must equal sso_account_id.
Optionally validate the expected AWS Organization using one profile from the
selected SSO session. The guard calls only DescribeOrganization, not
ListAccounts:
accounts = list_accounts(
pattern="AWS-Infosec-{account_id}",
sso_session="control-tower",
organization_id="o-exampleorgid",
organization_profile="AWSAdministratorAccess-999999999999",
)
Both organization arguments must be supplied together, and the validation
profile must reference the selected sso_session.
AWS Organizations
Organizations is an optional, explicit source of the live account inventory:
from runacross import map_accounts
from runacross.organizations import list_accounts
accounts = list_accounts(
organization_id="o-exampleorgid",
exclude_accounts=["111111111111"],
)
results = map_accounts(
who_am_i,
accounts=accounts,
role_name="SecurityAuditRole",
)
The organization ID is a safety check, not a selector. AWS uses the source
credentials to determine which organization is visible. RunAcross verifies
that it matches the expected ID and then returns accounts whose current
Organizations State is ACTIVE.
Call list_accounts() without an ID when that guard is not needed.
Discovered Account objects include the Organizations name and root email
address; treat those fields as sensitive. Account redacts the email in
repr() output, but the value remains on the object.
Account-by-Region execution
map_accounts calls the callback once per account. For a Cartesian product of
accounts and Regions, use map_account_regions. The callback receives the
Region as a third argument, and the Session is already set to that Region:
from runacross import map_account_regions
def list_instance_ids(session, account, region):
instance_ids = []
paginator = session.client("ec2").get_paginator("describe_instances")
for page in paginator.paginate():
for reservation in page["Reservations"]:
instance_ids.extend(
instance["InstanceId"] for instance in reservation["Instances"]
)
return instance_ids
results = map_account_regions(
list_instance_ids,
accounts=["111111111111", "222222222222"],
regions=["eu-west-1", "us-east-1"],
role_name="SecurityAuditRole",
)
for result in results:
if result.success:
print(f"{result.account.id} {result.region}: {result.value}")
else:
print(f"{result.account.id} {result.region}: {result.phase}: {result.error}")
Each result has a structured identity, result.target, with .account and
.region. Role assumes the target role once per account and reuses those
credentials across Regions.
Pass discover_regions=True to list enabled Regions in each account with
Account Management ListRegions (ENABLED and ENABLED_BY_DEFAULT only).
An explicit regions= list then acts as an allowlist:
from runacross.regions import list_enabled_regions
# Same Regions for every account, chosen by you:
# regions=["eu-west-1", "us-east-1"]
#
# Whatever each account currently has enabled:
results = map_account_regions(
list_instance_ids,
accounts=accounts,
role_name="SecurityAuditRole",
discover_regions=True,
exclude_regions=["ap-east-1"],
)
# Or discover from one Session outside the executor:
regions = list_enabled_regions()
list_enabled_regions queries AWS. It does not use Boto3 endpoint metadata.
Filters
Both executors accept exclude_accounts. map_account_regions also accepts
exclude_regions. Filters preserve input order and do not silently deduplicate.
Both discovery helpers accept limit= to keep the first matching accounts
instead of slicing the list by hand.
results = map_account_regions(
list_instance_ids,
accounts=accounts,
regions=["eu-west-1", "us-east-1", "ap-southeast-2"],
role_name="SecurityAuditRole",
exclude_accounts=["111111111111"],
exclude_regions=["ap-southeast-2"],
)
Handling failures
One failed account or Region does not cancel the others:
for result in results:
if result.success:
use(result.value)
elif result.phase == "auth":
report_access_problem(result.account, result.error)
else:
report_worker_problem(result.account, result.error)
auth covers role assumption and profile resolution. In 0.1 this phase was
named assume_role. Failed results also expose result.error_code when the
stored exception is a Botocore ClientError; RunAcross does not parse
exception text and does not store credentials.
RunResults and RegionResults preserve input order and provide:
results.successful
results.failed
results.success_count
results.failure_count
results.failures_by_phase()
results.summary()
results.to_dicts()
to_dicts() is conversion, not printing. Use it for JSON, CSV, logging, or
your own reports:
import json
print(json.dumps(results.summary()))
print(json.dumps(results.to_dicts(), indent=2))
Serialized records include account IDs and optional names, not Organizations email addresses. Callback return values are included as-is; encode them in your application if they are not JSON-serializable.
Use result.unwrap() when code wants the typed value or the stored exception.
RunAcross does not retry the callback because arbitrary functions may not be idempotent. Botocore may still retry individual AWS requests on clients that RunAcross or your callback create.
Concurrency
RunAcross uses ThreadPoolExecutor, which is suitable for the network-bound
work performed by Boto3. The default is 10 workers:
results = map_accounts(
who_am_i,
accounts=accounts,
role_name="SecurityAuditRole",
max_workers=5,
)
More workers do not imply linear speedups. High concurrency can increase throttling, connection use, memory use, and Lambda duration.
RunAcross-owned clients use Botocore standard retries with three total attempts. Override them explicitly when needed:
from botocore.config import Config
results = map_accounts(
who_am_i,
accounts=accounts,
role_name="SecurityAuditRole",
botocore_config=Config(
retries={"mode": "adaptive", "total_max_attempts": 5},
),
)
This configuration applies only to clients RunAcross creates, such as STS or
the Account Management client used for Region discovery. Pass a Config to
clients created inside your callback to configure their retries.
Progress
map_accounts and map_account_regions still wait for every target and
return results in input order. Pass on_result to observe each completion
as it happens:
from runacross import show_progress
results = map_accounts(
who_am_i,
accounts=accounts,
auth=Profile("AWS-Infosec-{account_id}"),
on_result=show_progress(),
)
show_progress() rewrites one status line on stderr when that stream is a
TTY:
runacross 47/186 ok 44 auth 2 worker 1 111122223333
In CI, pipes, and Lambda it stays silent. It is not a tqdm or rich integration; those tools can consume the same callback:
def on_result(result, *, completed, total):
log.info("%s %s/%s", result.account.id, completed, total)
results = map_accounts(..., on_result=on_result)
An exception from on_result stops the run. With discover_regions=True,
discovery authentication failures are reported first, then worker completions.
Source credentials
By default, Role uses boto3.Session() and the standard Boto3 credential
provider chain. It works with configured environment credentials, profiles,
IAM Identity Center, web identity, ECS task roles, EC2 instance profiles,
Lambda execution roles, and GitHub Actions OIDC.
Pass source_session= on Role (or on map_accounts when using role_name=)
to use a specific profile as the identity that assumes into every account.
The source Session must have a Region. RunAcross copies it onto each assumed
Session and raises ValueError before calling STS if it is missing. Configure
AWS_DEFAULT_REGION, the profile's region, or boto3.Session(region_name=...).
Profile uses each named profile's own region unless map_account_regions
supplies a Region. Assumed Sessions do not refresh automatically. Callbacks
should finish within the STS session lifetime, which defaults to one hour.
Pass duration_seconds (900-43200, still subject to the role maximum) on
Role to request a shorter or longer session.
IAM permissions
For Role, the source identity needs sts:AssumeRole for the target roles.
Organizations discovery additionally needs organizations:ListAccounts; using
the organization ID guard also needs organizations:DescribeOrganization.
Enabled-Region discovery needs account:ListRegions.
For Profile, the Identity Center permission set (or other profile identity)
needs only the service permissions used by the callback. Local profile
discovery needs no AWS permissions. Its optional organization guard needs
organizations:DescribeOrganization on organization_profile, but does not
need organizations:ListAccounts.
The assumed role, when using Role, needs only the service permissions used
by the callback. See docs/iam.md for restrictive examples and
trust-policy requirements.
Running in AWS Lambda
Lambda execution-role credentials are discovered automatically. Package RunAcross and its Boto3 dependency with the function or in a layer so the versions are controlled by your deployment.
All callbacks must finish before the handler returns. Do not leave RunAcross
work running in the background between Lambda invocations. Tune max_workers
for the function's memory, timeout, and downstream AWS quotas. Role is the
usual Lambda strategy; local AWS CLI profiles are not available there.
Security
RunAcross does not persist or return STS credentials, add telemetry, or create non-AWS service clients. Library logging is silent unless the application configures it. RunAcross never deliberately adds credentials to logs, but callback exception messages are emitted at DEBUG and must not contain secrets.
See SECURITY.md for vulnerability reporting.
Examples
Minimal scripts live in examples/:
- AssumeRole: examples/caller_identity.py
- Identity Center profiles: examples/identity_center_profiles.py
- Organizations discovery: examples/organization_accounts.py
- Account and Region execution: examples/ec2_inventory.py
- Result export: examples/export_results.py
Public API
The supported surface is the names in runacross.__all__ plus:
runacross.profiles.list_accountsrunacross.organizations.list_accountsrunacross.regions.list_enabled_regions
Everything else, including runacross.sts and helpers in runacross.models
that are not re-exported, is internal and may change without a deprecation.
See docs/api.md.
Roadmap
The next likely additions are optional: account-ID verification on Profile, structured profile names on results, and Organizations OU selection. The default path stays two calls and a callback. See docs/roadmap.md.
Contributing
Development uses pytest, Ruff, and mypy. See CONTRIBUTING.md.
RunAcross is licensed under the Apache License 2.0.
Metadata
Release files for runacross 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| runacross-0.4.0.tar.gz | 52.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| runacross-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 81.4 kB
Release files / runacross-0.4.0.tar.gz
| Download URL | runacross-0.4.0.tar.gz |
|---|---|
| Size | 52.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ae86975aae9e7a72cb515711857ca6c349be0b332c235cd4e46b286b9a2ebe68
|
|
BLAKE2b-256 checksum How to use checksums |
abefd1e7eed58c6c21a35e07e446313e084c329ccf56314a18427ee2356a0199
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.
Transparency logRelease files / runacross-0.4.0-py3-none-any.whl
| Download URL | runacross-0.4.0-py3-none-any.whl |
|---|---|
| Size | 29.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
288cd4aa27d0b5165b46eb47b2128f02bcbe8dd8c456bff07722ee4ad4e63328
|
|
BLAKE2b-256 checksum How to use checksums |
d1261a3a9c6cdd635edb23de732b7a8554acad380122493b0bb244d9ccbc7948
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 26, 2026.
Transparency log