Beispiel #1
0
    def instantiate(self, args):
        cfg = self.cfg

        loader = CSVAccountLoader(
            url=args.loader_url,
            max_age=args.loader_max_age,
            delimiter=args.loader_delimiter,
            id_attr=cfg("id_attr", must_exist=True),
            str_template=args.loader_str_template,
            include_attrs=cfg("include_attrs", type=List(Str), default=[]),
            exclude_attrs=cfg("exclude_attrs", type=List(Str), default=[]),
            no_verify=args.loader_no_verify,
        )

        return loader
Beispiel #2
0
 def regional_from_cli(cls, parser, argv, cfg):
     parser.add_argument(
         "--days",
         type=int,
         default=cfg("days", type=Int, default=7),
         help="How many days of past maintenance to display",
     )
     parser.add_argument(
         "--dx",
         dest="requested_dxs",
         action="append",
         default=cfg("dx_conn_ids", type=List(Str), default=[]),
         help="Direct Connect ID",
     )
     parser.add_argument(
         "--no-color",
         action="store_true",
         default=cfg("no_color", type=Bool, default=False),
         help="Disable color in terminal output",
     )
     parser.add_argument(
         "--verbose",
         action="store_true",
         default=cfg("verbose", type=Bool, default=False),
         help="Do not suppress the original SCHEDULED events",
     )
     args = parser.parse_args(argv)
     return cls(**vars(args))
Beispiel #3
0
    def from_cli(cls, parser, argv, cfg):
        # If the user does not provide their own from_cli method, we make sure
        # that we invoke parse_args and add the --region flag on their behalf.
        parser.add_argument(
            "--region",
            "-r",
            action=AppendWithoutDefault,
            default=cfg("region", type=List(Str), default=[]),
            dest="regions",
            metavar="REGION",
            help="region in which to run commands",
        )

        # Delegate out to the user defining their command
        command = cls.regional_from_cli(parser, argv, cfg)

        # Make sure that a user's subclass of the RegionalCommand has called our
        # constructor where the 'regions' instance variable is set to a non-zero
        # length list of regions to parse. On a side note, we do not use the
        # 'required' keyword with ArgumentParser because we want to allow a
        # default value to be provided via the user config file. So, we check to
        # see if this is an empty list here, and if so, then complain and exit.
        if not command.regions:
            parser.error("No regions specified")

        return command
Beispiel #4
0
    def regional_from_cli(cls, parser, argv, cfg):
        parser.add_argument(
            "--exclude-block",
            action="append",
            dest="exclude_blocks",
            default=cfg("exclude_block", type=List(IPNet), default=[]),
            help="exclude CIDR blocks from check",
        )

        args = parser.parse_args(argv)
        return cls(**vars(args))
    def regional_from_cli(cls, parser, argv, cfg):
        parser.add_argument(
            "attribute",
            nargs="*",
            help="vpc attributes to query",
            default=cfg(
                "attribute",
                type=List(Str),
                default=["enableDnsSupport", "enableDnsHostnames"],
            ),
        )

        args = parser.parse_args(argv)
        return cls(**vars(args))
Beispiel #6
0
    def from_cli(cls, parser, argv, cfg):
        parser.add_argument(
            "--role",
            "-r",
            action="append",
            help="Limit results to role name",
            default=cfg("role", type=List(Str), default=[]),
        )
        parser.add_argument(
            "--trust",
            "-t",
            action="store_true",
            help="List trust relationships for each role",
            default=cfg("trust", type=Bool),
        )

        args = parser.parse_args(argv)
        return cls(**vars(args))
Beispiel #7
0
    def regional_from_cli(cls, parser, argv, cfg):
        parser.add_argument(
            "--list-clusters", action="store_true", help="list the EKS cluster",
        )
        parser.add_argument(
            "--cluster",
            dest="clusters",
            action=AppendWithoutDefault,
            default=cfg("cluster", type=List(Str), default=[]),
            help="EKS cluster name",
        )
        parser.add_argument(
            "--namespace",
            "-n",
            dest="namespaces",
            action=AppendWithoutDefault,
            default=cfg("namespace", type=List(Str), default=["default"]),
            help="EKS cluster namespace",
        )
        parser.add_argument(
            "--output",
            "-o",
            default=cfg("namespace", type=Str),
            help="Specify output format of kubectl",
        )

        # Note: normally one would not prefix an awsrun command's arguments with
        # '--awsrun-', but this is a special exception because there could be
        # valid kubectl args interspersed among the awsrun command flags. To
        # avoid namespace collisions, the kubectl command args are prefixed.
        parser.add_argument(
            "--awsrun-output-dir",
            metavar="DIR",
            default=cfg("awsrun_output_dir"),
            help="output directory to write results to separate files",
        )

        parser.add_argument(
            "--awsrun-annotate",
            choices=["json", "yaml", "text"],
            default=cfg("awsrun_annotate", type=StrMatch("^(json|yaml|text)$")),
            help="annotate each result with account/region/cluster/namespace",
        )

        # Let's gobble up any native kubuctl args that should not be used with
        # this wrapper, which decides the server, context, user, etc ... based
        # on how the awsrun wrapper is invoked. We also don't include these
        # flags in the help message as they are really part of the kubectl tool.
        # We capture these flags so we can check for their presence later and
        # remind users not to specify them.
        prohibited = [
            "kubeconfig",
            "context",
            ("server", "s"),
            "user",
            "username",
            "password",
            "client-key",
            "client-certificate",
            "client-authority",
            "as",
            "as-group",
            "token",
        ]

        for arg in prohibited:
            if isinstance(arg, tuple):
                parser.add_argument(f"--{arg[0]}", f"-{arg[1]}", help=argparse.SUPPRESS)
            else:
                parser.add_argument(f"--{arg}", help=argparse.SUPPRESS)

        # We parse the known args and then collect the rest as those will be
        # passed to the kubectl command later.
        args, remaining = parser.parse_known_args(argv)
        args.kubectl_args = remaining

        for arg in (a[0] if isinstance(a, tuple) else a for a in prohibited):
            attr = arg.replace("-", "_")
            if getattr(args, attr) is not None:
                parser.error("Do not specify --{arg} with awsrun kubectl")
            delattr(args, attr)

        if args.awsrun_annotate and args.output:
            if args.awsrun_annotate != "text":
                if args.awsrun_annotate != args.output:
                    parser.error(
                        "When specifying --awsrun-annotate, you do not need the --output flag"
                    )

        return cls(**vars(args))
Beispiel #8
0
    def regional_from_cli(cls, parser, argv, cfg):
        time_spec = parser.add_argument_group("Time specification")
        time_spec.add_argument(
            "--hours",
            metavar="N",
            type=int,
            default=cfg("hours", type=Int, default=1),
            help="retrieve the last N hours of events",
        )
        time_spec.add_argument(
            "--start",
            type=_isodate,
            help="lookup events starting at YYYY-MM-DDTHH:MM:SS-00:00",
        )
        time_spec.add_argument(
            "--end",
            type=_isodate,
            help="lookup events ending at YYYY-MM-DDTHH:MM:SS-00:00",
        )

        parser.add_argument(
            "--max-events",
            metavar="N",
            type=int,
            default=cfg("max_events", type=Int, default=1000),
            help="limit # of events retrieved",
        )

        filters = parser.add_argument_group("Event filters")
        mut_excl = filters.add_mutually_exclusive_group()
        mut_excl.add_argument(
            "--all",
            action="store_true",
            default=cfg("all", type=Bool, default=False),
            help="include read-only events",
        )
        mut_excl.add_argument(
            "--console",
            action="store_true",
            default=cfg("console", type=Bool, default=False),
            help="include only ConsoleLogin events",
        )
        mut_excl.add_argument(
            "--attribute",
            "-a",
            dest="attributes",
            action=AppendAttributeValuePair,
            default=cfg("attributes", type=Dict(Str, List(Str)), default={}),
            help="filter using attribute in form of ATTR=VALUE",
        )

        tui = parser.add_argument_group("TUI options")
        tui.add_argument(
            "--interactive",
            "-i",
            action="store_true",
            default=cfg("interactive", type=Bool, default=False),
            help="enter interactive mode to view results",
        )
        tui.add_argument(
            "--vertical",
            action="store_true",
            default=cfg("vertical", type=Bool, default=False),
            help="use vertical layout for TUI",
        )
        tui.add_argument(
            "--color",
            choices=TUI_COLOR_THEMES,
            default=cfg("color", type=Choice(*TUI_COLOR_THEMES), default="cyan"),
            help=f"use color scheme: {', '.join(TUI_COLOR_THEMES)}",
        )

        args = parser.parse_args(argv)

        # If user doesn't specify any filters, then exclude the read-only
        # events as there are far too many of these typically. While our
        # argument parser can support multipe key and values, the AWS
        # CloudTrail API is lacking considerably in ability to specify
        # filters. One can only use a single lookup attribute and that
        # attribute can only have a single value. We allow the user to
        # explicity set their own filter or use one of our the shorthand
        # filters such as --all or --console.

        if not (args.all or args.console or args.attributes):
            args.attributes["ReadOnly"] = ["false"]

        elif args.console:
            args.attributes["EventName"] = ["ConsoleLogin"]

        elif len(args.attributes) > 1:
            parser.error(f"only one lookup attribute may be used per AWS")

        elif any(len(v) > 1 for v in args.attributes.values()):
            parser.error(f"only one lookup value may be specified per AWS")

        elif any(a not in LOOKUP_ATTRIBUTES for a in args.attributes):
            parser.error(
                f"invalid attribute, must be one of {', '.join(LOOKUP_ATTRIBUTES)}"
            )

        # If no time spec flags provided, default to last of 1 hour of events.
        if not (args.hours or args.start or args.end):
            args.hours = 1

        # If only --hours was specified, then compute a start and end time as
        # our CLICommand doesn't support --last.
        if args.hours and not (args.start or args.end):
            args.end = datetime.now(timezone.utc)
            args.start = args.end - timedelta(hours=args.hours)

        elif args.hours and (args.start or args.end):
            parser.error("must specify either --hours OR --start/--end flags")

        elif not (args.start and args.end):
            parser.error("must specify both --start and --end flags")

        # Only allow use of TUI options with --interactive flag
        if args.vertical and not args.interactive:
            parser.error("can only use --vertical with --interactive mode")

        del args.all
        del args.hours
        del args.console
        return cls(**vars(args))
Beispiel #9
0
    def from_cli(cls, parser, argv, cfg):
        parser.add_argument(
            "--verbose",
            "-v",
            action="store_true",
            help="include JSON policy body",
            default=cfg("verbose", type=Bool),
        )

        include_group = parser.add_argument_group(
            "limit flags", "Search only these identity and policy types")
        include_group.add_argument(
            "--roles",
            action="store_true",
            help="search role policies only",
            default=cfg("roles", type=Bool),
        )
        include_group.add_argument(
            "--users",
            action="store_true",
            help="search user policies only",
            default=cfg("users", type=Bool),
        )
        include_group.add_argument(
            "--groups",
            action="store_true",
            help="search group policies only",
            default=cfg("groups", type=Bool),
        )
        include_group.add_argument(
            "--inline",
            action="store_true",
            help="search inline policies only",
            default=cfg("inline", type=Bool),
        )
        include_group.add_argument(
            "--attached",
            action="store_true",
            help="search attached policies only",
            default=cfg("attached", type=Bool),
        )

        search_group = parser.add_argument_group(
            "filter flags",
            "Search only policies associated with identity name")
        search_group.add_argument(
            "--role-name",
            metavar="NAME",
            action="append",
            dest="role_names",
            help="filter on role name",
            default=cfg("role_name", type=List(Str), default=[]),
        )
        search_group.add_argument(
            "--user-name",
            metavar="NAME",
            action="append",
            dest="user_names",
            help="filter on user name",
            default=cfg("user_name", type=List(Str), default=[]),
        )
        search_group.add_argument(
            "--group-name",
            metavar="NAME",
            action="append",
            dest="group_names",
            help="filter on group name",
            default=cfg("group_name", type=List(Str), default=[]),
        )

        other_group = parser.add_argument_group(
            "other filters",
            "Search only policies matching these other options")
        action_group = other_group.add_mutually_exclusive_group()
        action_group.add_argument(
            "--action-name",
            metavar="NAME",
            action="append",
            dest="action_names",
            help="include policy if it contains the action",
            default=cfg("action_name", type=List(Str), default=[]),
        )
        action_group.add_argument(
            "--not-action-name",
            metavar="NAME",
            action="append",
            dest="not_action_names",
            help="include policy if it does not contains the action",
            default=cfg("not_action_name", type=List(Str), default=[]),
        )
        policy_group = other_group.add_mutually_exclusive_group()
        policy_group.add_argument(
            "--policy-name",
            metavar="NAME",
            action="append",
            dest="policy_names",
            help="include if policy name starts with",
            default=cfg("policy_name", type=List(Str), default=[]),
        )
        policy_group.add_argument(
            "--not-policy-name",
            metavar="NAME",
            action="append",
            dest="not_policy_names",
            help="exclude if policy name starts with",
            default=cfg("not_policy_name", type=List(Str), default=[]),
        )

        args = parser.parse_args(argv)
        return cls(**vars(args))
Beispiel #10
0
def _cli(csp):
    """Parses command line arguments and invokes the awsrun CLI.

    This function may exit and terminate the Python program. It is the driver of
    the main interactive command line tool and may print output to the console.
    """

    # Load the main user configuration file, which is used extensively to
    # provide default values for argparse arguments. This allows users to
    # specify default values for commonly used flags.
    config = Config.from_file(csp.config_filename())

    # Build a callable to simplify access to the 'CLI' section of the config.
    cfg = partial(config.get, "CLI", type=Str)

    # Argument parsing for awsrun is performed in four distinct stages because
    # arguments are defined by the main CLI program, plug-ins can define their
    # own arguments, and commands can also define their own arguments.
    #
    #   1. Parse *known* args for the main CLI
    #   2. Parse *known* args for the plug-ins
    #   3. Parse *remaining* args to grab the command and its args
    #   4. Parse the command's args later in cmdmgr.py
    #
    # Visually, here is a representation of the above;
    #
    #             awsrun and plug-in args          command             cmd args
    #        vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv vvvvvvvvvvvvv vvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # awsrun --account 123 --saml-username pete access_report --region us-east-1 --verbose
    #        ^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    #           stage 1           stage 2          stage 3    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    #                                                                    stage 4
    #
    # In stage 1, this function calls `parse_known_args` in argparse, which does
    # not error out if an unknown argument is encountered. Why do we do this?
    # Because there may be arguments intermixed with the main awsrun args that
    # are intended for a plug-in, so we don't want argparse to terminate the
    # main program, which the more commonly used `parse_args` would do. Note:
    # the main awsrun arguments on the CLI can be specified anywhere before the
    # command. Note: flags defined in a command with the same name as a flag in
    # the main CLI will be eaten by stage 1 processing. I.e. if the main CLI
    # defines a flag called --count and a command author builds a command that
    # also takes a flag called --count, then in an invocation such:
    #
    #   awsrun --account 123 access_report --count
    #
    # The --count arg would never make it to stage 4 processing as it would
    # be shadowed and consumed by stage 1.
    #
    # In stage 2, this function uses the PluginManager to load plug-ins, which
    # may have registered additional command line arguments on the main parser.
    # The PluginManager uses `parse_known_args` when loading each plug-in, again
    # for the same reason as above. There may be arguments destined for a
    # different plug-in that has yet to be loaded, so we don't want to error
    # out. Note: plug-in flags can also shadow command flags as described above.
    # This is why plug-in author's should use a prefix on their flags to
    # minimize chance of collision.
    #
    # In stage 3, this function registers arguments for a help flag, the awsrun
    # command name, and gathers the remaining arguments after the command name.
    # It then calls `parse_args` as all command line arguments should have been
    # consumed at this point. If there are any extra arguments, argparse will
    # error and exit out with an appropriate message.
    #
    # Finally, in stage 4, this function uses the CommandManager to load the
    # command via the name and collected arguments from stage 3. The command
    # manager creates a new argument parser internally to parse the arguments
    # that were sent to the command as each command has the option to register
    # command line arguments.

    # To minimize the likelihood of shadowing flags defined by command authors,
    # this little hack wraps an internal method of argparse to track use of
    # flag names across all instances of ArgumentParser. We use two of these
    # during the 4 stages of CLI parsing. Stage 1-3 use one and stage 4 uses
    # a second ArgumentParser, so this prevents a command author from defining
    # the same flag name that we already use in the main CLI or one that a
    # plug-in author uses. We exclude -h from the check as we want both the
    # main CLI to have a -h as well as a command. The command's -h will not be
    # shadowed as the stage 1 and 2 do not include a -h option. We only add
    # the -h option in stage 3, and by then, the command name is parsed as
    # well as the remainder of the argv line, so -h is not processed if it
    # comes after the command name. This provides the behavior we want. If
    # you have a -h anywhere before the command name, you get the main help
    # for all of the awsrun CLI options as well as the options for any flags
    # defined by plugins. If you specify a command and then a -h afterwards,
    # you get the help that the command author includes for their command.
    prevent_option_reuse(exclude=("-h", "--help"))

    # STAGE 1 Argument Processing (see description above)

    # Do not add_help here or --help will not include descriptions of arguments
    # that were registered by the plug-ins. We will add the help flag in stage 3
    # processing.
    parser = argparse.ArgumentParser(
        add_help=False,
        allow_abbrev=False,
        formatter_class=RawAndDefaultsFormatter,
        description=SHORT_DESCRIPTION,
    )

    acct_group = parser.add_argument_group("account selection options")
    acct_group.add_argument(
        "--account",
        metavar="ACCT",
        action=AppendWithoutDefault,
        default=cfg("account", type=List(Str), default=[]),
        dest="accounts",
        help="run command on specified list of accounts",
    )

    acct_group.add_argument(
        "--account-file",
        metavar="FILE",
        type=argparse.FileType("r"),
        default=cfg("account_file", type=File),
        help="filename containing accounts (one per line)",
    )

    acct_group.add_argument(
        "--metadata",
        metavar="ATTR",
        nargs="?",
        const=True,
        help="summarize metadata that can be used in filters",
    )

    acct_group.add_argument(
        "--include",
        metavar="ATTR=VAL",
        action=AppendAttributeValuePair,
        default=cfg("include", type=Dict(Str, List(Any)), default={}),
        help="include filter for accounts",
    )

    acct_group.add_argument(
        "--exclude",
        metavar="ATTR=VAL",
        action=AppendAttributeValuePair,
        default=cfg("exclude", type=Dict(Str, List(Any)), default={}),
        help="exclude filter for accounts",
    )

    parser.add_argument(
        "--threads",
        metavar="N",
        type=int,
        default=cfg("threads", type=Int, default=10),
        help="number of concurrent threads to use",
    )

    parser.add_argument(
        "--force",
        action="store_true",
        help="do not prompt user if # of accounts is > 1",
    )

    parser.add_argument("--version",
                        action="version",
                        version="%(prog)s " + awsrun.__version__)

    parser.add_argument(
        "--log-level",
        default=cfg("log_level",
                    type=Choice("DEBUG", "INFO", "WARN", "ERROR"),
                    default="ERROR"),
        choices=["DEBUG", "INFO", "WARN", "ERROR"],
        help="set the logging level",
    )

    parser.add_argument(
        "--cmd-path",
        action=AppendWithoutDefault,
        metavar="PATH",
        default=cfg("cmd_path",
                    type=List(Str),
                    default=[csp.default_command_path()]),
        help="directory or python package used to find commands",
    )

    # Parse only the _known_ arguments as there may be additional args specified
    # by the user that are intended for consumption by the account loader plugin
    # or the auth plugin. We save the remaining args and will pass those to the
    # plugin manager responsible for loading the plugins.
    args, remaining_argv = parser.parse_known_args()

    # With the log level now available from the CLI options, setup logging so it
    # can be used immediately by the various python modules in this package.
    logging.basicConfig(
        level=args.log_level,
        format=
        "%(asctime)s %(name)s %(levelname)s [%(threadName)s] %(message)s",
    )

    # STAGE 2 Argument Processing (see description above).

    # The plugin manager will load the two plugins and handle command line
    # parsing of any arguments registered by the plugins.
    plugin_mgr = PluginManager(config, parser, args, remaining_argv)
    plugin_mgr.parse_args("Accounts", default="awsrun.plugins.accts.Identity")
    plugin_mgr.parse_args("Credentials",
                          default=csp.default_session_provider())

    # STAGE 3 Argument Processing (see description above).

    # The help flag is added to the parser _after_ loading all of the plugins
    # because plugins can register their own flags, which means if the help flag
    # were added before this point, and a user passed the -h flag, it would not
    # include descriptions for any of the args registered by the plugins.
    parser.add_argument("-h", "--help", action="help")
    parser.add_argument("command", nargs="?", help="command to execute")
    parser.add_argument("arguments",
                        nargs=argparse.REMAINDER,
                        default=[],
                        help="arguments for command")

    # Now we parse the remaining args that were not consumed by the plugins,
    # which will typically include the awsrun command name and any of its args.
    # If there are extra args or unknown args, parse_args will exit here with an
    # error message and usage string. Note that we obtain the remaining unused
    # argv from the plugin manager as well as the namespace to add these last
    # arguments.
    args = parser.parse_args(plugin_mgr.remaining_argv, plugin_mgr.args)

    # Use the plugin manager to create the actual account loader that will be
    # used to load accounts and metadata for accounts.
    account_loader = plugin_mgr.instantiate("Accounts", must_be=AccountLoader)

    # Check to see if user is inquiring about the metadata associated with
    # accounts. If they pass --metadata by itself, print out a list of all
    # possible attribute names. If they pass an arg to --metadata, such as
    # "--metadata BU", then print out all the possible values of that attr,
    # so they can build filters for it.
    if args.metadata:
        attrs = account_loader.attributes()
        if args.metadata in attrs:
            print(f"Metadata values for '{args.metadata}' attribute:\n")
            print("\n".join(sorted(attrs[args.metadata])))
        elif attrs:
            print("Valid metadata attributes:\n")
            print("\n".join(sorted(attrs)))
        else:
            print("No metadata attributes available")
        sys.exit(0)

    # Check to see if the user wants to load additional accounts from a file
    # specified on the command line. If so, the account IDs will be appended to
    # any accounts defined on the command line or in the user config.
    if args.account_file:
        args.accounts.extend(a.strip() for a in args.account_file
                             if not (a.isspace() or a.startswith("#")))

    # Obtain a list of account *objects* for the specified account IDs. The
    # resulting objects will depend upon the account loader plugin used. Some
    # plugins will return rich objects with attributes containing metadata and
    # others may just return a simple list of IDs as strings. The point is that
    # this is an opaque object that will be passed to the runner and then to the
    # command being run.
    accounts = account_loader.accounts(args.accounts, args.include,
                                       args.exclude)

    # If we get to here and there are still 0 accounts, that means there were
    # no accounts specified via --accounts, no accounts specified in the user
    # config, no accounts specified in a separate file, or none of the
    # specified accounts matched the filters, so we just exit.
    if not accounts:
        print("No accounts selected", file=sys.stderr)
        sys.exit(1)

    # The command manager will be used to search, parse command arguments, and
    # instantiate the command that was specified on the CLI. It can also provide
    # a list of all commands found in the paths provided.
    command_mgr = CommandManager.from_paths(*args.cmd_path)

    # If no command was supplied, then print the list of accounts that were
    # selected along with a list of all the valid and known commands. This
    # allows users to test filters to see which accounts will be acted upon.
    if not args.command:
        _print_accounts(accounts)
        _print_valid_commands(command_mgr.commands())
        sys.exit(1)

    # STAGE 4 Argument Processing (see description above). When the command
    # manager loads the command, it will create a new argument parser, so
    # command author's can define any arguments they might want. After this
    # step, all command line arguments have been fully processed.
    try:
        command = command_mgr.instantiate_command(
            args.command, args.arguments,
            partial(config.get, "Commands", args.command))

    # Most exceptions are passed upwards, but we explicitly catch a failure when
    # trying to instantiate the command selected by the user, so we can include a
    # list of valid commands that the command manager knows about. The exception
    # re-raised so it will be handled by the same error handling logic in main().
    except Exception:
        _print_valid_commands(command_mgr.commands(), out=sys.stderr)
        raise

    # Safety check to make sure user knows they are impacting more than one
    # account. This can be disabled with the -f flag.
    if len(accounts) > 1 and not args.force:
        _ask_for_confirmation(accounts)

    # Load up a session provider to hand out creds for the runner.
    session_provider = plugin_mgr.instantiate("Credentials",
                                              must_be=SessionProvider)

    # This is the main entry point into the awsrun API. Note: the entirety of
    # awsrun can be used without the need of the CLI. One only needs a list of
    # accounts, an awsrun.runner.Command, and an awsrun.session.SessionProvider.
    runner = AccountRunner(session_provider, args.threads)
    elapsed = runner.run(command, accounts, key=account_loader.acct_id)

    # Show a quick summary on how long the command took to run.
    pluralize = "s" if len(accounts) != 1 else ""
    print(
        f"\nProcessed {len(accounts)} account{pluralize} in {timedelta(seconds=elapsed)}",
        file=sys.stderr,
    )