Skip to main content

RunAcross

CI PyPI

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}.

Pass verify_account_id=True to call sts:GetCallerIdentity after the profile credentials resolve and fail authentication when the returned Account does not match the target:

Profile("AWS-Infosec-{account_id}", verify_account_id=True)

This is off by default so the ordinary path does not add an extra STS call. Each result exposes the resolved profile_name or role_name without credentials.

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; DescribeOrganization with the ID guard; ListAccountsForParent and ListOrganizationalUnitsForParent with parent_id

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.

To list accounts under a root or OU instead of the whole organization:

accounts = list_accounts(
    organization_id="o-exampleorgid",
    parent_id="ou-exampleroot-workloads",
)

Nested OUs are included by default. Pass include_nested=False for direct children only. Filter or inspect the returned list before map_accounts. Invalid IDs and organization mismatches raise ConfigError.

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. result.profile_name and result.role_name identify which profile or role was selected.

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. Listing under a root or OU needs organizations:ListAccountsForParent, and nested OUs also need organizations:ListOrganizationalUnitsForParent. 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/:

Public API

The supported surface is the names in runacross.__all__ plus:

  • runacross.profiles.list_accounts
  • runacross.organizations.list_accounts
  • runacross.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: richer Organizations selectors and timeouts with honest thread semantics. 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.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for runacross 0.6.0
File Size Uploaded
runacross-0.6.0.tar.gz 56.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for runacross 0.6.0
File Interpreter ABI Platform
runacross-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 87.7 kB

Release files / runacross-0.6.0.tar.gz

Download URL runacross-0.6.0.tar.gz
Size 56.5 kB
Tags Source
SHA-256 checksum
How to use checksums
201cc760f32bd1139a2d8bb3df7c56b5390f67b3b32fdf50f4bd3090a5d927d6
BLAKE2b-256 checksum
How to use checksums
2b8edfdbdf1f691c084640320c1d6cc9009e3ce15fc1b55819aefb8d4d8f6e51
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

Release files / runacross-0.6.0-py3-none-any.whl

Download URL runacross-0.6.0-py3-none-any.whl
Size 31.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a0064e263d5e95b429bcec1b53613f05ef733b0a4f7a0825a81a65c0bb32fd34
BLAKE2b-256 checksum
How to use checksums
2204f038d36b00b0a4ca65fdb1a6cf5a16458404196b08c2636a9b53ff48f6c3
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

Release history Release notifications | RSS feed

0.7.0

2 release files

0.6.1

2 release files

This release

0.6.0 This release

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page