From db0d4e6cda5990c2c6064cff8973ba57d5cf43d2 Mon Sep 17 00:00:00 2001 From: "Kazmier, Peter" Date: Wed, 12 Jun 2024 12:17:33 -0400 Subject: [PATCH 1/4] feat: new account loader plugin w/ HTTP auth support The existing account loaders in the awsrun.acctload and their respective CLI-counterparts in awsrun.plugins.accts do not support HTTP auth: - awsrun.acctload.JSONAccountLoader (awsrun.plugins.accts.JSON) - awsrun.acctload.YAMLAccountLoader (awsrun.plugins.accts.YAML) - awsrun.acctload.CSVAccountLoader (awsrun.plugins.accts.CSV) This change introduces a new account loader, which will eventually replace those mentioned above: - awsrun.acctload.URLAccountLoader (awsrun.plugins.accts.URLLoader) The new loader can parse JSON, YAML, and CSV data depending on the parser provided (library usage) or parser specified via the `parser` configuration key (CLI usage). In addition, the new loader supports HTTP basic, digest, ntlm, and oauth2 authentication methods depending on the auth method provided (library usage) or auth method specified via the `auth` configuration key (CLI usage). Sample usage via the library: # Library usage from awsrun.acctload import * # JSON example with basic HTTP auth loader = URLAccountLoader( "https://example.com/data.json", parser=JSONFormatter(), auth=HTTPBasic(user, pw), ) # CSV example with OAuth2 HTTP auth loader = URLAccountLoader( "https://example.com/data.csv", parser=CSVFormatter(delimiter=","), auth=HTTPOAuth2("https://token.example.com", user, pw), ) Sample usage via the CLI configuration file: Accounts: plugin: awsrun.plugins.accts.URLLoader options: url: https://example.com/data.json parser: json auth: oauth2 auth_options: token_url: https://token.example.com --- src/awsrun/acctload.py | 169 ++++++++++++ src/awsrun/plugins/accts/__init__.py | 376 ++++++++++++++++++++++++++- 2 files changed, 543 insertions(+), 2 deletions(-) diff --git a/src/awsrun/acctload.py b/src/awsrun/acctload.py index a2cb312..7b4b329 100644 --- a/src/awsrun/acctload.py +++ b/src/awsrun/acctload.py @@ -60,6 +60,7 @@ import requests import yaml +from requests.auth import AuthBase from requests_file import FileAdapter from awsrun.cache import PersistentExpiringValue @@ -916,6 +917,174 @@ def __init__(self, name_regexp=None): super().__init__(accts) +class CSVParser: + """Returns a list of dicts from a buffer of CSV text. + + To override options passed to `csv.DictReader`, specify them as keyword + arguments in the constructor. By default, the `delimiter` is `","` and + `skipinitialspace` is `True`. + """ + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.kwargs.setdefault("delimiter", ",") + self.kwargs.setdefault("skipinitialspace", True) + + def __call__(self, text): + buf = io.StringIO(text.strip()) + return list(csv.DictReader(buf, **self.kwargs)) + + +class JSONParser: + """Returns a list or dict from a buffer of JSON-formatted text. + + To override options passed to `json.loads`, specify them as keyword + arguments in the constructor. + """ + + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __call__(self, text): + return json.loads(text, **self.kwargs) + + +class YAMLParser: + """Returns a list or dict from a buffer of YAML-formatted text. + + To override options passed to `yaml.safe_load`, specify them as keyword + arguments in the constructor. + """ + + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __call__(self, text): + return yaml.safe_load(text, **self.kwargs) + + +class HTTPOAuth2(AuthBase): + """Attaches an OAuth2 bearer token to the given `requests.Request` object. + + Use `token_url` to specify the token provider's URL. The `client_id` and + `client_secret` specify the credentials used to authenticate with the + token provider. Three additional keyword parameters are accepted: + + `scope` + : Default is "AppIdClaimsTrust". + + `grant_type` + : Default is "client_credentials". + + `intent` + : Default is "awsrun account loader plugin" + """ + + def __init__( + self, + token_url, + username, + password, + scope="AppIdClaimsTrust", + grant_type="client_credentials", + intent="awsrun account loader plugin", + ): + self.url = token_url + self.data = {} + self.data["client_id"] = username + self.data["client_secret"] = password + self.data["scope"] = scope + self.data["grant_type"] = grant_type + self.data["intent"] = intent + + def _get_token(self): + resp = requests.post(self.url, data=self.data) + resp.raise_for_status() + return resp.json()["access_token"] + + def __call__(self, req): + req.headers["Authorization"] = f"Bearer {self._get_token()}" + return req + + +class URLAccountLoader(MetaAccountLoader): + """Returns an `AccountLoader` with accounts loaded from a URL. + + Loaded accounts will include metadata associated with each account in the + document retrieved from the `url`. File based URLs can be used to load + data from a local file. This data will be parsed as JSON by default. To + override, use `parser` to specify a callable that accepts the text and + returns a list or dict of accounts (see `MetaAccountLoader`). To cache the + results, specify a non-zere number of seconds in `max_age`. The default + location on disk is the system temp directory in a file called + `awsrun.dat`, which can be overrided via `cache_path`. + + Given the following JSON: + + { + "Accounts": [ + {"id": "100200300400", "env": "prod", "status": "active"}, + {"id": "200300400100", "env": "non-prod", "status": "active"}, + {"id": "300400100200", "env": "non-prod", "status": "suspended"} + ] + } + + The account loader will build account objects with the following attribute + names: `id`, `env`, `status`. Assume the above JSON is returned from + http://example.com/accts.json: + + loader = URLAccountLoader('http://example.com/accts.json', path=['Accounts']) + accts = loader.accounts() + + # Let's inspect the 1st account object and its metadata + assert accts[0].id == '100200300400' + assert accts[0].env == 'prod' + assert accts[0].status == 'active' + + URLAccountLoader is a subclass of the `MetaAccountLoader`, which loads + accounts from a set of dicts. As such, the remainder of the parameters in + the constructor -- `id_attr`, `path`, `str_template`, `include_attrs`, and + `exclude_attrs` -- are defined in the constructor of `MetaAccountLoader`. + """ + + def __init__( + self, + url, + parser=JSONParser(), + auth=None, + max_age=0, + id_attr="id", + path=None, + str_template=None, + include_attrs=None, + exclude_attrs=None, + no_verify=False, + cache_path=None, + ): + + session = requests.Session() + session.mount("file://", FileAdapter()) + + def load_cache(): + r = session.get(url, auth=auth, verify=not no_verify) + r.raise_for_status() + return parser(r.text) + + if not cache_path: + cache_path = Path(tempfile.gettempdir(), "awsrun.dat") + + accts = PersistentExpiringValue(load_cache, cache_path, max_age=max_age) + + super().__init__( + accts.value(), + id_attr=id_attr, + path=[] if path is None else path, + str_template=str_template, + include_attrs=[] if include_attrs is None else include_attrs, + exclude_attrs=[] if exclude_attrs is None else exclude_attrs, + ) + + class AbstractAccount: """Abstract base class used by `MetaAccountLoader` to represent an account and its metadata. diff --git a/src/awsrun/plugins/accts/__init__.py b/src/awsrun/plugins/accts/__init__.py index 35e9890..b16d072 100644 --- a/src/awsrun/plugins/accts/__init__.py +++ b/src/awsrun/plugins/accts/__init__.py @@ -42,14 +42,25 @@ `awsrun.plugmgr.Plugin` that returns a `awsrun.acctload.AccountLoader`. """ +import getpass +import os +from pathlib import Path + +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from requests_ntlm import HttpNtlmAuth + from awsrun.acctload import ( CSVAccountLoader, + CSVParser, + HTTPOAuth2, IdentityAccountLoader, JSONAccountLoader, + JSONParser, + URLAccountLoader, YAMLAccountLoader, + YAMLParser, ) -from awsrun.config import List, Str, Int, Bool, URL - +from awsrun.config import URL, Any, Bool, Choice, Dict, File, Int, List, Str from awsrun.plugmgr import Plugin @@ -606,3 +617,364 @@ def instantiate(self, args): ) return loader + + +class URLLoader(Plugin): + """CLI plug-in that loads accounts and metadata from a file/url. + + ## Overview + + Accounts specified on the awsrun CLI via the `--account` or + `--account-file` will be validated against the list of accounts loaded + from this URL. More importantly, loaded accounts will include metadata + associated with each account from the document. This metadata can be used + to select accounts using the `--include` and `--exclude` awsrun CLI flags. + Given the following JSON document: + + [ + {"id": "100200300400", "env": "prod", "status": "active"}, + {"id": "200300400100", "env": "non-prod", "status": "active"}, + {"id": "300400100200", "env": "non-prod", "status": "suspended"} + ] + + Users could select only the "active" accounts via the `awsrun.cli` by using + the metadata filter options. The following would select account numbers + "100200300400" and "200300400100": + + $ awsrun --include status=active aws ec2 describe-vpcs --region us-east-1 + + Additionally, this metadata is made available to command authors for use + within their commands. The account loader would build account objects with + the following attribute names: `id`, `env`, and `status`. Command authors + are provided access to these account objects in their user-defined commands + via a parameter to `awsrun.runner.Command.execute`: + + class CLICommand(Command): + def execute(self, session, acct): + # The acct parameter contains the attributes from the JSON + return f'{acct.env} account {acct.id} is {acct.status}\\n' + + In cases where the JSON key names are not valid Python identifiers, they are + munged. Leading digits are prefixed with underscores, non-alpha numeric + characters are replaced with underscores, and keywords are appended with an + underscore. + + Instead of specifying accounts as a JSON array of objects as shown above, + the JSON can be specified as a single object with account IDs as keys and + metadata as values such as: + + { + "100200300400": {"env": "prod", "status": "active"}, + "200300400100": {"env": "non-prod", "status": "active"}, + "300400100200": {"env": "non-prod", "status": "suspended"} + } + + The above will create the same list account objects as the first version + because this loader assumes a top-level JSON object is the container of + accounts. The account objects created will contain the `id` attribute + because the default value for the ID attribute is `id`. + + Similarly, the JSON can be specified as follows, where the account ID is + used in the top-level object key as well as the object value metadata: + + { + "100200300400": {"id": "100200300400", "env": "prod", "status": "active"}, + "200300400100": {"id": "200300400100", "env": "non-prod", "status": "active"}, + "300400100200": {"id": "300400100200", "env": "non-prod", "status": "suspended"} + } + + If the account list is not at the top-level of the JSON, a path can be + specified to point the loader to the correct location in the JSON. For + example, a path list of "aws" and "accounts" is required to parse the + following JSON: + + { + "aws": + { + "accounts": + [ + {"id": "100200300400", "env": "prod", "status": "active"}, + {"id": "200300400100", "env": "non-prod", "status": "active"}, + {"id": "300400100200", "env": "non-prod", "status": "suspended"} + ] + } + } + + The `URLLoader` can parse JSON, YAML, or CSV files loaded from a URL. Data + can be loaded from a local file via the "file:///" URL syntax. By default, + JSON is assumed. This can be overrided via the `parser` option. Parsing + options such as the CSV delimiter can be specified via `parser_options`. + When parsing CSV files, the header row is required as its names are the + key names for the metadata associated with each account. + + If the URL requires authentication, specify one of the supported types: + "none", "basic", "digest", "ntlm", or "oauth2" via the `auth` option. By + default, no authentication is used. Authentication options for the URL + such as username and password can be specified via `auth_options`. + + ## Configuration + + Options with an asterisk are mandatory and must be provided: + + Accounts: + plugin: awsrun.plugins.accts.URLLoader + options: + url: STRING* + auth: STRING ("none", "basic", "digest", "ntlm", "oauth2") + auth_options: + username: STRING + password: STRING + token_url: URL (oauth2 only) + scope: STRING (oauth2 only, default "AppIdClaimsTrust") + grant_type: STRING (oauth2 only, default "client_credentials") + parser: STRING ("json", "yaml", "csv") + parser_options: + delimiter: STRING (csv only, default ",") + max_age: INTEGER + id_attr: STRING* + path: + - STRING + str_template: STRING + include_attrs: + - STRING + exclude_attrs: + - STRING + no_verify: BOOLEAN + + ## Plug-in Options + + Some options can be overridden on the awsrun CLI via command line flags. + In those cases, the CLI flags are specified next to the option name below: + + `url`, `--loader-url` + : Load the data from the specified URL. To load a local file, use + `file:///absolute/path/data.json`. This value **must** be provided via the + user configuration or as an awsrun command line argument. + + `auth` + : Identifies the authentication type required by the URL. The default + value is "none". Valid options include "none", "basic", "digest", "ntml", + and "oauth2". All but "none" require that username` and `password` keys + in `auth_options`. In addition, "oauth2" requires `token_url` key. + + `auth_options` + : Provides options required for the specified `auth` type chosen. The use + of "basic", "digest", "ntml", or "oauth2" require `username` and + `password` keys in `auth_options`. Use of "oauth2" also requires a + `token_url` that points to the token provider. Three optional keys can be + provided with "oauth2" to override defaults: `scope` ("AppIdClaimsTrust"), + `grant_type` ("client_credentials"), or `intent` ("awsrun account loader + plugin"). + + `parser` + : Specifies the parser used to parse data from the URL. The default value + is "json". Valid options include "json", "yaml", and "csv". + + `parser_options` + : Provides additional options to the parser method used to parse the data + from the URL: `json.loads`, `yaml.safe_load`, and `csv.DictReader`. Most + often this would be used to change the `delimiter` used with the CSV + parser. + + `max_age`, `--loader-max-age` + : Cache the data retrieved from the URL for the specified number of seconds. + The default value is `0`, which disables caching. This can be useful for + servers that are slow to generate the account list. + + `id_attr` + : Identifies the JSON/YAML key name that contains the AWS account ID. This + value **must** be provided so awsrun can identify the account number + associated with each account in the JSON/YAML. + + `path` + : Specifies the location within the JSON/YAML that contains the array of + accounts or object of accounts. If specified, this must be a list of key + names to traverse the JSON/YAML. The default assumes the accounts are at + the top-level. + + `str_template`, `--loader-str-template` + : Controls how accounts are formatted as strings. This is a [Python format + string](https://docs.python.org/3.7/library/string.html#format-string-syntax) + that can include any of the included attributes. For example, `"{id}:{env}"` + or `"{id}-{env}"` assuming `id` and `env` are JSON/YAML/CSV key names. + + `include_attrs` + : Include only the specified list of JSON/YAML/CSV key names from the + object metadata. If this option is not supplied, all JSON/YAML/CSV keys + are included as attributes on the account objects created by this loader. + + `exclude_attrs` + : Exclude the specified list of JSON/YAML/CSV key names from the object + metadata. If this option is not supplied, no key names are excluded as + attributes on the account objects created by this loader. + + `no_verify`, `--loader-no-verify` + : Disable HTTP certificate verification. This is not advisable and user will + be warned on the command line if verification has been disabled. The default + value is `false`. + """ + + def __init__(self, parser, cfg): + super().__init__(parser, cfg) + + # Define the arguments that we want to allow a user to override via the + # main CLI. Any CLI args added via add_argument will be commingled with + # the main awsrun args, so they are prefixed with '--loader-' to lessen + # chance of collision. + group = parser.add_argument_group("account loader options") + group.add_argument( + "--loader-url", + metavar="URL", + default=cfg("url", type=URL, must_exist=True), + help="URL to account data (also supports file:///path/to/file)", + ) + + group.add_argument( + "--loader-auth", + metavar="STRING", + default=cfg( + "auth", + type=Choice("none", "basic", "digest", "ntml", "oauth2"), + default="none", + ), + help="Authentication type required by the URL", + ) + + group.add_argument( + "--loader-username", + metavar="USER", + help="username (or client_id) for account loader authentication", + ) + + group.add_argument( + "--loader-password", + metavar="PASS", + help="password (or client_secret) for account loader authentication", + ) + + group.add_argument( + "--loader-parser", + metavar="STRING", + default=cfg("parser", type=Choice("json", "yaml", "csv"), default="json"), + help="Parser used on data from URL", + ) + + group.add_argument( + "--loader-no-verify", + action="store_true", + default=cfg("no_verify", type=Bool, default=False), + help="disable cert verification for HTTP requests", + ) + + group.add_argument( + "--loader-cache-path", + metavar="PATH", + type=Path, + default=cfg("cache_path", type=File), + help="max age for cached URL data", + ) + + group.add_argument( + "--loader-max-age", + metavar="SECS", + type=int, + default=cfg("max_age", type=Int, default=0), + help="max age for cached URL data", + ) + + group.add_argument( + "--loader-str-template", + metavar="STRING", + default=cfg("str_template"), + help="format string used to display an account", + ) + + def instantiate(self, args): + cfg = self.cfg + + auth_options = cfg("auth_options", type=Dict(Str, Any), default={}) + + # Check and set auth options if using authentication. + if args.loader_auth != "none": + + # Command line flags take priority + if args.loader_username: + auth_options["username"] = args.loader_username + if args.loader_password: + auth_options["password"] = args.loader_password + + # If they don't exist in user config, then pick defaults + if "username" not in auth_options: + auth_options["username"] = _default_username() + if "password" not in auth_options: + auth_options["password"] = _default_password(auth_options["username"]) + + if args.loader_auth == "oauth2" and "token_url" not in auth_options: + raise TypeError( + "with oauth2 authentication token_url must be set in config: Accounts->options->auth_options->token_url" + ) + + auth_types = { + "none": _HTTPNone, + "basic": HTTPBasicAuth, + "digest": HTTPDigestAuth, + "ntlm": HttpNtlmAuth, + "oauth2": HTTPOAuth2, + } + + try: + auth = auth_types[args.loader_auth](**auth_options) + except TypeError as e: + raise TypeError( + f"incompatible auth_options specified in config: Accounts->options->auth_options: {e}" + ) + + parsers = { + "json": JSONParser, + "yaml": YAMLParser, + "csv": CSVParser, + } + parser_options = cfg("parser_options", type=Dict(Str, Any), default={}) + + try: + parser = parsers[args.loader_parser](**parser_options) + except TypeError as e: + raise TypeError( + f"incompatible parser_options specified in config: Accounts->options->parser_options: {e}" + ) + + loader = URLAccountLoader( + url=args.loader_url, + parser=parser, + auth=auth, + max_age=args.loader_max_age, + id_attr=cfg("id_attr", must_exist=True), + path=cfg("path", type=List(Str), default=[]), + 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, + cache_path=args.loader_cache_path, + ) + + return loader + + +def _default_username(): + return os.environ.get("AWSRUN_LOADER_USERNAME", None) or getpass.getuser() + + +def _default_password(user): + return os.environ.get("AWSRUN_LOADER_PASSWORD", None) or getpass.getpass( + f"Account loader password for {user}? " + ) + + +class _HTTPNone: + """HTTPNone is a no-op auth type for requests library.""" + + def __init__(self, *args, **kwargs): + return None + + def __call__(self, req): + return req From d8a09e787d871eeda32b73f38744fc43698087b1 Mon Sep 17 00:00:00 2001 From: "Kazmier, Peter" Date: Thu, 13 Jun 2024 11:43:23 -0400 Subject: [PATCH 2/4] fix: delay prompting user in URLLoader plug-in At the time the URLLoader plug-in is instantiated, we don't know if the URLAccountLoader will even need to make an HTTP call as it may have cached data. So, if the user has not specified a password via CLI args, env vars, or config file, we don't want to prematurely prompt them unless URLAccountLoader really needs to make an HTTP request. --- src/awsrun/plugins/accts/__init__.py | 65 ++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/src/awsrun/plugins/accts/__init__.py b/src/awsrun/plugins/accts/__init__.py index b16d072..5a94291 100644 --- a/src/awsrun/plugins/accts/__init__.py +++ b/src/awsrun/plugins/accts/__init__.py @@ -46,7 +46,7 @@ import os from pathlib import Path -from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from requests.auth import AuthBase, HTTPBasicAuth, HTTPDigestAuth from requests_ntlm import HttpNtlmAuth from awsrun.acctload import ( @@ -903,11 +903,16 @@ def instantiate(self, args): if args.loader_password: auth_options["password"] = args.loader_password - # If they don't exist in user config, then pick defaults - if "username" not in auth_options: - auth_options["username"] = _default_username() - if "password" not in auth_options: - auth_options["password"] = _default_password(auth_options["username"]) + # If a username and password has not been provided via CLI flags + # or via the configuration file, we'll fallback to environment + # variables if they exist, or lastly we'll prompt the user + # interactively. BUT, we don't want to do that here because the + # URLAccountLoader caches data, so we might not need to make an + # HTTP call, and thus prompting the user would be unneeded (and + # annoying). So, instead, we use DeferPrompting to wrap the + # various requests HTTP*Auth classes. This will defer the + # instantiation of those classes until `requests` invokes the + # callable `auth` parameter to its various methods. if args.loader_auth == "oauth2" and "token_url" not in auth_options: raise TypeError( @@ -915,19 +920,13 @@ def instantiate(self, args): ) auth_types = { - "none": _HTTPNone, - "basic": HTTPBasicAuth, - "digest": HTTPDigestAuth, - "ntlm": HttpNtlmAuth, - "oauth2": HTTPOAuth2, + "none": _HTTPNone(), + "basic": _DeferPrompting(HTTPBasicAuth, auth_options), + "digest": _DeferPrompting(HTTPDigestAuth, auth_options), + "ntlm": _DeferPrompting(HttpNtlmAuth, auth_options), + "oauth2": _DeferPrompting(HTTPOAuth2, auth_options), } - - try: - auth = auth_types[args.loader_auth](**auth_options) - except TypeError as e: - raise TypeError( - f"incompatible auth_options specified in config: Accounts->options->auth_options: {e}" - ) + auth = auth_types[args.loader_auth] parsers = { "json": JSONParser, @@ -970,6 +969,36 @@ def _default_password(user): ) +# Helper class to wrap one of `requests` auth classes to defer instantiation +# of those classes until `requests` actually needs to use the auth data. This +# is used to avoid interactively prompting a user for their password if one +# had not been specified via CLI args or their config file. The default will +# come from the env variable if set, otherwise the user is prompted. But we +# don't want to prompt when we instantiated the `requests` auth classes +# because at that time, we do not know if the account loader data has been +# cached and thus not require making an HTTP call. So, why bother prompting +# the user in that case? +class _DeferPrompting(AuthBase): + def __init__(self, auth_class, auth_options): + self.auth_class = auth_class + self.auth_options = auth_options + + def __call__(self, req): + if "username" not in self.auth_options: + self.auth_options["username"] = _default_username() + if "password" not in self.auth_options: + self.auth_options["password"] = _default_password( + self.auth_options["username"] + ) + try: + auth = self.auth_class(**self.auth_options) + except TypeError as e: + raise TypeError( + f"incompatible auth_options specified in config: Accounts->options->auth_options: {e}" + ) + return auth(req) + + class _HTTPNone: """HTTPNone is a no-op auth type for requests library.""" From 9971780b3c27a0709f9d65ed1c9c1ffc3e5906f7 Mon Sep 17 00:00:00 2001 From: "Kazmier, Peter" Date: Wed, 3 Jul 2024 09:41:37 -0400 Subject: [PATCH 3/4] style: make black happy --- src/awsrun/cloudwatch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/awsrun/cloudwatch.py b/src/awsrun/cloudwatch.py index 0d22c87..4b79744 100644 --- a/src/awsrun/cloudwatch.py +++ b/src/awsrun/cloudwatch.py @@ -29,6 +29,7 @@ print(datetime, value) """ + import logging import math from collections import defaultdict From 3438955c4fdaf3a1c84f2fcc49e5c4f36d2cc3da Mon Sep 17 00:00:00 2001 From: "Kazmier, Peter" Date: Wed, 3 Jul 2024 10:04:53 -0400 Subject: [PATCH 4/4] style: make flake8 happy this time --- src/awsrun/commands/aws/list_lambdas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/awsrun/commands/aws/list_lambdas.py b/src/awsrun/commands/aws/list_lambdas.py index b730864..cc9321b 100644 --- a/src/awsrun/commands/aws/list_lambdas.py +++ b/src/awsrun/commands/aws/list_lambdas.py @@ -102,7 +102,7 @@ def regional_execute(self, session, acct, region): total = len(by_role[role]) public = len([fn for fn in by_role[role] if _is_public(fn)]) print( - f"{acct}/{region}: role={role} total={total} private={total-public} public={public}", + f"{acct}/{region}: role={role} total={total} private={total - public} public={public}", file=out, )