diff --git a/stacklet/client/platform/cli.py b/stacklet/client/platform/cli.py index ca3c461..86724e6 100644 --- a/stacklet/client/platform/cli.py +++ b/stacklet/client/platform/cli.py @@ -10,18 +10,26 @@ import jwt import requests -from stacklet.client.platform.cognito import CognitoUserManager -from stacklet.client.platform.commands import commands -from stacklet.client.platform.config import StackletConfig -from stacklet.client.platform.context import StackletContext -from stacklet.client.platform.formatter import Formatter -from stacklet.client.platform.utils import click_group_entry, default_options +from .cognito import CognitoUserManager +from .commands import commands +from .config import DEFAULT_CONFIG_FILE, StackletConfig +from .context import StackletContext +from .utils import default_options, setup_logging @click.group() @default_options() @click.pass_context -def cli(*args, **kwargs): +def cli( + ctx, + config, + output, + cognito_user_pool_id, + cognito_client_id, + cognito_region, + api, + v, +): """ Stacklet CLI @@ -77,7 +85,25 @@ def cli(*args, **kwargs): --after $after_token \\ list """ - click_group_entry(*args, **kwargs) + setup_logging(v) + + config_items = [cognito_user_pool_id, cognito_client_id, cognito_region, api] + if any(config_items) and not all(config_items): + raise Exception( + "All options must be set for config items: --cognito-user-pool-id, " + + "--cognito-client-id, --cognito-region, and --api" + ) + + raw_config = None + if all(config_items): + raw_config = { + "cognito_user_pool_id": cognito_user_pool_id, + "cognito_client_id": cognito_client_id, + "region": cognito_region, + "api": api, + } + + ctx.obj = StackletContext(raw_config=raw_config, config_file=config, output_format=output) @cli.command(short_help="Configure stacklet-admin cli") @@ -89,9 +115,7 @@ def cli(*args, **kwargs): @click.option("--auth-url", prompt="(SSO) Auth Url", default="") @click.option("--cubejs", prompt="Stacklet cube.js endpoint", default="") @click.option("--location", prompt="Config File Location", default="~/.stacklet/config.json") # noqa -@click.pass_context def configure( - ctx, api, region, cognito_client_id, @@ -140,7 +164,7 @@ def configure( ) @click.option( "--location", - default="~/.stacklet/config.json", + default=str(DEFAULT_CONFIG_FILE), type=click.Path(path_type=pathlib.Path, dir_okay=False), show_default=True, ) @@ -263,19 +287,17 @@ def auto_configure(url, prefix, idp, location): @cli.command() -@click.pass_context -def show(ctx): +@click.pass_obj +def show(obj): """ Show your config """ - context = StackletContext(ctx.obj["config"], ctx.obj["raw_config"]) - fmt = Formatter.registry.get(ctx.obj["output"])() - if os.path.exists(os.path.expanduser(StackletContext.DEFAULT_ID)): - with open(os.path.expanduser(StackletContext.DEFAULT_ID), "r") as f: - id_details = jwt.decode(f.read(), options={"verify_signature": False}) + fmt = obj.formatter() + if id_token := obj.credentials.id_token(): + id_details = jwt.decode(id_token, options={"verify_signature": False}) click.echo(fmt(id_details)) click.echo() - click.echo(fmt(context.config.to_json())) + click.echo(fmt(obj.config.to_json())) @cli.command(short_help="Login to Stacklet") @@ -295,23 +317,25 @@ def login(ctx, username, password): If password is not passed in, your password will be prompted """ - context = StackletContext(ctx.obj["config"], ctx.obj["raw_config"]) + context = ctx.obj + config = context.config + can_sso_login = context.can_sso_login() # sso login - if context.can_sso_login() and not any([username, password]): + if can_sso_login and not any([username, password]): from stacklet.client.platform.vendored.auth import BrowserAuthenticator BrowserAuthenticator( - authority_url=context.config.auth_url, - client_id=context.config.cognito_client_id, - idp_id=context.config.idp_id, + authority_url=config.auth_url, + client_id=config.cognito_client_id, + idp_id=config.idp_id, )() return - elif not context.can_sso_login() and not any([username, password]): + elif not can_sso_login and not any([username, password]): click.echo( "To login with SSO ensure that your configuration includes " + "auth_url, and cognito_client_id values." ) - raise Exception() + ctx.exit(1) # handle login with username/password if not username: @@ -319,14 +343,11 @@ def login(ctx, username, password): if not password: password = click.prompt("Password", hide_input=True) manager = CognitoUserManager.from_context(context) - res = manager.login( + id_token, access_token = manager.login( user=username, password=password, ) - if not os.path.exists(os.path.dirname(os.path.expanduser(StackletContext.DEFAULT_CREDENTIALS))): - os.makedirs(os.path.dirname(os.path.expanduser(StackletContext.DEFAULT_CREDENTIALS))) - with open(os.path.expanduser(StackletContext.DEFAULT_CREDENTIALS), "w+") as f: # noqa - f.write(res) + context.credentials.write(id_token, access_token) for c in commands: diff --git a/stacklet/client/platform/client.py b/stacklet/client/platform/client.py index 7e6a3ff..c11dd22 100644 --- a/stacklet/client/platform/client.py +++ b/stacklet/client/platform/client.py @@ -3,30 +3,27 @@ # platform client using cli -from pathlib import Path import jmespath # import the commands so the registry is populated when the client is instantiated -import stacklet.client.platform.commands # noqa -from stacklet.client.platform.context import StackletContext -from stacklet.client.platform.executor import StackletGraphqlExecutor -from stacklet.client.platform.utils import _PAGINATION_OPTIONS, get_token +from . import commands # noqa +from .config import DEFAULT_CONFIG_FILE +from .context import StackletContext +from .executor import StackletGraphqlExecutor +from .utils import _PAGINATION_OPTIONS def platform_client(pager=False, expr=False): # for more pythonic experience, pass expr=True to de-graphqlize the result # for automatic pagination handling, pass pager=True - if ( - not Path(StackletContext.DEFAULT_CONFIG).expanduser().exists() - or not Path(StackletContext.DEFAULT_CREDENTIALS).expanduser().exists() - ): + context = StackletContext(raw_config={}) + if not DEFAULT_CONFIG_FILE.exists() or not context.credentials.api_token(): raise ValueError("Please configure and authenticate on stacklet-admin cli") d = {} - ctx = StackletContext(raw_config={}) - token = get_token() - executor = StackletGraphqlExecutor(ctx, token) + + executor = StackletGraphqlExecutor(context) for k, snippet in StackletGraphqlExecutor.registry.items(): # assert k == snippet.name, f"{k} mismatch {snippet.name}" diff --git a/stacklet/client/platform/cognito.py b/stacklet/client/platform/cognito.py index 331aa56..58e2513 100644 --- a/stacklet/client/platform/cognito.py +++ b/stacklet/client/platform/cognito.py @@ -71,14 +71,15 @@ def create_user(self, user, password, email, phone_number, permanent=True): self.log.debug(res) return True - def login(self, user, password): + def login(self, user, password) -> tuple[str, str]: res = self.client.initiate_auth( ClientId=self.user_pool_client_id, AuthFlow="USER_PASSWORD_AUTH", AuthParameters={"USERNAME": user, "PASSWORD": password}, ) self.log.debug("Authentication Success") - return res["AuthenticationResult"]["AccessToken"] + auth = res["AuthenticationResult"] + return auth["IdToken"], auth["AccessToken"] def ensure_group(self, user, group) -> bool: try: diff --git a/stacklet/client/platform/commands/account.py b/stacklet/client/platform/commands/account.py index 1628f96..5ead833 100644 --- a/stacklet/client/platform/commands/account.py +++ b/stacklet/client/platform/commands/account.py @@ -7,7 +7,7 @@ from stacklet.client.platform.executor import StackletGraphqlExecutor, _run_graphql, snippet_options from stacklet.client.platform.graphql import StackletGraphqlSnippet -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @StackletGraphqlExecutor.registry.register("list-accounts") @@ -273,7 +273,6 @@ class ValidateAccountSnippet(StackletGraphqlSnippet): @click.group(short_help="Run account queries/mutations") @default_options() -@click.pass_context def account(*args, **kwargs): """ Query against and Run mutations against Account objects in Stacklet. @@ -287,86 +286,85 @@ def account(*args, **kwargs): $ stacklet account --output json list """ - click_group_entry(*args, **kwargs) @account.command() @snippet_options("list-accounts") -@click.pass_context -def list(ctx, **kwargs): +@click.pass_obj +def list(obj, **kwargs): """ List cloud accounts in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="list-accounts", variables=kwargs)) + click.echo(_run_graphql(obj, name="list-accounts", variables=kwargs)) @account.command() @snippet_options("add-account") -@click.pass_context -def add(ctx, **kwargs): +@click.pass_obj +def add(obj, **kwargs): """ Add an account to Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="add-account", variables=kwargs)) + click.echo(_run_graphql(obj, name="add-account", variables=kwargs)) @account.command() @snippet_options("remove-account") -@click.pass_context -def remove(ctx, **kwargs): +@click.pass_obj +def remove(obj, **kwargs): """ Remove an account from Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="remove-account", variables=kwargs)) + click.echo(_run_graphql(obj, name="remove-account", variables=kwargs)) @account.command() @snippet_options("update-account") -@click.pass_context -def update(ctx, **kwargs): +@click.pass_obj +def update(obj, **kwargs): """ Update an account in platform """ - click.echo(_run_graphql(ctx=ctx, name="update-account", variables=kwargs)) + click.echo(_run_graphql(obj, name="update-account", variables=kwargs)) @account.command() @snippet_options("show-account") -@click.pass_context -def show(ctx, **kwargs): +@click.pass_obj +def show(obj, **kwargs): """ Show an account in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="show-account", variables=kwargs)) + click.echo(_run_graphql(obj, name="show-account", variables=kwargs)) @account.command() @snippet_options("validate-account") -@click.pass_context -def validate(ctx, **kwargs): +@click.pass_obj +def validate(obj, **kwargs): """ Validate an account in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="validate-account", variables=kwargs)) + click.echo(_run_graphql(obj, name="validate-account", variables=kwargs)) @account.command() @snippet_options("list-accounts") -@click.pass_context -def validate_all(ctx, **kwargs): +@click.pass_obj +def validate_all(obj, **kwargs): """ Validate all accounts in Stacklet """ - result = _run_graphql(ctx=ctx, name="list-accounts", variables=kwargs, raw=True) + result = _run_graphql(obj, name="list-accounts", variables=kwargs, raw=True) # get all the accounts count = result["data"]["accounts"]["pageInfo"]["total"] kwargs["last"] = count - result = _run_graphql(ctx=ctx, name="list-accounts", variables=kwargs, raw=True) + result = _run_graphql(obj, name="list-accounts", variables=kwargs, raw=True) account_provider_pairs = [ {"provider": r["node"]["provider"], "key": r["node"]["key"]} for r in result["data"]["accounts"]["edges"] ] for pair in account_provider_pairs: - click.echo(_run_graphql(ctx=ctx, name="validate-account", variables=pair)) + click.echo(_run_graphql(obj, name="validate-account", variables=pair)) diff --git a/stacklet/client/platform/commands/accountgroup.py b/stacklet/client/platform/commands/accountgroup.py index b736e87..32cbba4 100644 --- a/stacklet/client/platform/commands/accountgroup.py +++ b/stacklet/client/platform/commands/accountgroup.py @@ -5,7 +5,7 @@ from stacklet.client.platform.executor import StackletGraphqlExecutor, _run_graphql, snippet_options from stacklet.client.platform.graphql import StackletGraphqlSnippet -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @StackletGraphqlExecutor.registry.register("list-account-groups") @@ -306,76 +306,77 @@ class RemoveAccountGroupItem(StackletGraphqlSnippet): @click.group("account-group", short_help="Run account group queries/mutations") @default_options() -@click.pass_context def account_group(*args, **kwargs): - click_group_entry(*args, **kwargs) + """ + Manage account groups + """ @account_group.command() @snippet_options("list-account-groups") -@click.pass_context -def list(ctx, **kwargs): +@click.pass_obj +def list(obj, **kwargs): """ List account groups in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="list-account-groups", variables=kwargs)) + click.echo(_run_graphql(obj, name="list-account-groups", variables=kwargs)) @account_group.command() @snippet_options("add-account-group") -@click.pass_context -def add(ctx, **kwargs): +@click.pass_obj +def add(obj, **kwargs): """ Add account group """ - click.echo(_run_graphql(ctx=ctx, name="add-account-group", variables=kwargs)) + click.echo(_run_graphql(obj, name="add-account-group", variables=kwargs)) @account_group.command() @snippet_options("update-account-group") -@click.pass_context -def update(ctx, **kwargs): +@click.pass_obj +def update(obj, **kwargs): """ Update account group """ - click.echo(_run_graphql(ctx=ctx, name="update-account-group", variables=kwargs)) + click.echo(_run_graphql(obj, name="update-account-group", variables=kwargs)) @account_group.command() @snippet_options("show-account-group") -@click.pass_context -def show(ctx, **kwargs): +@click.pass_obj +def show(obj, **kwargs): """ Show account group """ - click.echo(_run_graphql(ctx=ctx, name="show-account-group", variables=kwargs)) + click.echo(_run_graphql(obj, name="show-account-group", variables=kwargs)) @account_group.command() @snippet_options("remove-account-group") -@click.pass_context -def remove(ctx, **kwargs): +@click.pass_obj +def remove(obj, **kwargs): """ Remove account group """ - click.echo(_run_graphql(ctx=ctx, name="remove-account-group", variables=kwargs)) + click.echo(_run_graphql(obj, name="remove-account-group", variables=kwargs)) @account_group.command() @snippet_options("add-account-group-item") -@click.pass_context -def add_item(ctx, **kwargs): +@click.pass_obj +def add_item(obj, **kwargs): """ Add account group item """ - click.echo(_run_graphql(ctx=ctx, name="add-account-group-item", variables=kwargs)) + click.echo(_run_graphql(obj, name="add-account-group-item", variables=kwargs)) @account_group.command() @snippet_options("remove-account-group-item") -@click.pass_context -def remove_item(ctx, **kwargs): +@click.pass_obj +def remove_item(obj, **kwargs): """ Remove account group item """ - click.echo(_run_graphql(ctx=ctx, name="remove-account-group-item", variables=kwargs)) + click.echo(_run_graphql(obj, name="remove-account-group-item", variables=kwargs)) diff --git a/stacklet/client/platform/commands/binding.py b/stacklet/client/platform/commands/binding.py index 1354fad..d7f9cb8 100644 --- a/stacklet/client/platform/commands/binding.py +++ b/stacklet/client/platform/commands/binding.py @@ -5,7 +5,7 @@ from stacklet.client.platform.executor import StackletGraphqlExecutor, _run_graphql, snippet_options from stacklet.client.platform.graphql import StackletGraphqlSnippet -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @StackletGraphqlExecutor.registry.register("list-bindings") @@ -260,79 +260,77 @@ class RunBindingSnippet(StackletGraphqlSnippet): @click.group(short_help="Run binding queries/mutations") @default_options() -@click.pass_context def binding(*args, **kwargs): """ Query and run mutations against bindings in Stacklet Platform """ - click_group_entry(*args, **kwargs) @binding.command() @snippet_options("list-bindings") -@click.pass_context -def list(ctx, **kwargs): +@click.pass_obj +def list(obj, **kwargs): """ List bindings in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="list-bindings", variables=kwargs)) + click.echo(_run_graphql(obj, name="list-bindings", variables=kwargs)) @binding.command() @snippet_options("show-binding") -@click.pass_context -def show(ctx, **kwargs): +@click.pass_obj +def show(obj, **kwargs): """ Show binding in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="show-binding", variables=kwargs)) + click.echo(_run_graphql(obj, name="show-binding", variables=kwargs)) @binding.command() @snippet_options("add-binding") -@click.pass_context -def add(ctx, **kwargs): +@click.pass_obj +def add(obj, **kwargs): """ Add binding in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="add-binding", variables=kwargs)) + click.echo(_run_graphql(obj, name="add-binding", variables=kwargs)) @binding.command() @snippet_options("update-binding") -@click.pass_context -def update(ctx, **kwargs): +@click.pass_obj +def update(obj, **kwargs): """ Update binding in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="update-binding", variables=kwargs)) + click.echo(_run_graphql(obj, name="update-binding", variables=kwargs)) @binding.command() @snippet_options("remove-binding") -@click.pass_context -def remove(ctx, **kwargs): +@click.pass_obj +def remove(obj, **kwargs): """ Remove binding in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="remove-binding", variables=kwargs)) + click.echo(_run_graphql(obj, name="remove-binding", variables=kwargs)) @binding.command() @snippet_options("deploy-binding") -@click.pass_context -def deploy(ctx, **kwargs): +@click.pass_obj +def deploy(obj, **kwargs): """ Deploy binding in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="deploy-binding", variables=kwargs)) + click.echo(_run_graphql(obj, name="deploy-binding", variables=kwargs)) @binding.command() @snippet_options("run-binding") -@click.pass_context -def run(ctx, **kwargs): +@click.pass_obj +def run(obj, **kwargs): """ Run a binding in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="run-binding", variables=kwargs)) + click.echo(_run_graphql(obj, name="run-binding", variables=kwargs)) diff --git a/stacklet/client/platform/commands/cube.py b/stacklet/client/platform/commands/cube.py index fbad67b..6a2f221 100644 --- a/stacklet/client/platform/commands/cube.py +++ b/stacklet/client/platform/commands/cube.py @@ -11,35 +11,33 @@ import click import requests -from stacklet.client.platform.context import StackletContext -from stacklet.client.platform.utils import click_group_entry, default_options, get_token +from ..context import StackletContext +from ..utils import default_options @click.group() @default_options() -@click.pass_context def cubejs(*args, **kwargs): """ Run arbitrary cubejs queries """ - click_group_entry(*args, **kwargs) @cubejs.command() @click.option("--query", help="Graphql Query or Mutation", default=sys.stdin) -@click.pass_context -def run(ctx, query): +@click.pass_obj +def run(obj, query): if isinstance(query, io.IOBase): query = query.read() - response = _run_query(ctx, json.loads(query)) + response = _run_query(obj, json.loads(query)) click.echo(pformat(response)) @cubejs.command() -@click.pass_context -def meta(ctx): - data = _get(ctx, "v1/meta") +@click.pass_obj +def meta(obj): + data = _get(obj, "v1/meta") cubes = {cube["name"]: cube for cube in data["cubes"]} for name in sorted(cubes): @@ -49,9 +47,9 @@ def meta(ctx): @cubejs.command() -@click.pass_context -def resource_counts(ctx): - response = _run_query(ctx, _resource_counts) +@click.pass_obj +def resource_counts(obj): + response = _run_query(obj, _resource_counts) data = response["data"] for row in data: date = datetime.fromisoformat(row["ResourceCounts.date"]) @@ -59,9 +57,8 @@ def resource_counts(ctx): click.echo(f"{date.date().isoformat()}: {count}") -def _run_query(ctx, query): - context = StackletContext(ctx.obj["config"], ctx.obj["raw_config"]) - token = get_token() +def _run_query(context: StackletContext, query): + token = context.credentials.api_token() url = f"https://{context.config.cubejs}/cubejs-api/v1/load" @@ -74,9 +71,8 @@ def _run_query(ctx, query): return response.json() -def _get(ctx, path: str): - context = StackletContext(ctx.obj["config"], ctx.obj["raw_config"]) - token = get_token() +def _get(context: StackletContext, path: str): + token = context.credentials.api_token() url = f"https://{context.config.cubejs}/cubejs-api/{path}" diff --git a/stacklet/client/platform/commands/graphql.py b/stacklet/client/platform/commands/graphql.py index 1bb7a3f..134f659 100644 --- a/stacklet/client/platform/commands/graphql.py +++ b/stacklet/client/platform/commands/graphql.py @@ -8,28 +8,26 @@ from stacklet.client.platform.executor import _run_graphql from stacklet.client.platform.graphql import AdHocSnippet -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @click.group() @default_options() -@click.pass_context def graphql(*args, **kwargs): """ Run arbitrary graphql snippets """ - click_group_entry(*args, **kwargs) @graphql.command() @click.option("--snippet", help="Graphql Query or Mutation", default=sys.stdin) -@click.pass_context -def run(ctx, snippet): +@click.pass_obj +def run(obj, snippet): if isinstance(snippet, io.IOBase): snippet = snippet.read() # snippet must be a subclass, not an instance, of StackletGraphqlSnippet. - click.echo(_run_graphql(ctx=ctx, snippet=_ad_hoc(snippet))) + click.echo(_run_graphql(obj, snippet=_ad_hoc(snippet))) def _ad_hoc(snippet): diff --git a/stacklet/client/platform/commands/policy.py b/stacklet/client/platform/commands/policy.py index 31f863e..1409f2e 100644 --- a/stacklet/client/platform/commands/policy.py +++ b/stacklet/client/platform/commands/policy.py @@ -6,7 +6,7 @@ from stacklet.client.platform.exceptions import InvalidInputException from stacklet.client.platform.executor import StackletGraphqlExecutor, _run_graphql, snippet_options from stacklet.client.platform.graphql import StackletGraphqlSnippet -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @StackletGraphqlExecutor.registry.register("list-policies") @@ -142,19 +142,20 @@ class ShowPolicy(StackletGraphqlSnippet): @click.group(short_help="Run policy queries") @default_options() -@click.pass_context def policy(*args, **kwargs): - click_group_entry(*args, **kwargs) + """ + Manage policies + """ @policy.command() @snippet_options("list-policies") -@click.pass_context -def list(ctx, **kwargs): +@click.pass_obj +def list(obj, **kwargs): """ List policies in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="list-policies", variables=kwargs)) + click.echo(_run_graphql(obj, name="list-policies", variables=kwargs)) def check_show_input(kwargs): @@ -166,25 +167,25 @@ def check_show_input(kwargs): @policy.command() @snippet_options("show-policy") -@click.pass_context -def show(ctx, **kwargs): +@click.pass_obj +def show(obj, **kwargs): """ Show policy in Stacklet by either name or uuid """ check_show_input(kwargs) - click.echo(_run_graphql(ctx=ctx, name="show-policy", variables=kwargs)) + click.echo(_run_graphql(obj, name="show-policy", variables=kwargs)) @policy.command() @snippet_options("show-policy") -@click.pass_context -def show_source(ctx, **kwargs): +@click.pass_obj +def show_source(obj, **kwargs): """ Show policy source in Stacklet by either name or uuid """ check_show_input(kwargs) click.echo( - _run_graphql(ctx=ctx, name="show-policy", variables=kwargs, raw=True)["data"]["policy"][ + _run_graphql(obj, name="show-policy", variables=kwargs, raw=True)["data"]["policy"][ "sourceYAML" ] ) diff --git a/stacklet/client/platform/commands/policycollection.py b/stacklet/client/platform/commands/policycollection.py index c33340d..3c8f27c 100644 --- a/stacklet/client/platform/commands/policycollection.py +++ b/stacklet/client/platform/commands/policycollection.py @@ -5,7 +5,7 @@ from stacklet.client.platform.executor import StackletGraphqlExecutor, _run_graphql, snippet_options from stacklet.client.platform.graphql import StackletGraphqlSnippet -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @StackletGraphqlExecutor.registry.register("list-policy-collections") @@ -254,76 +254,77 @@ class RemovePolicyCollection(StackletGraphqlSnippet): @click.group(short_help="Run policy collection queries/mutations") @default_options() -@click.pass_context def policy_collection(*args, **kwargs): - click_group_entry(*args, **kwargs) + """ + Manage policy collections + """ @policy_collection.command() @snippet_options("list-policy-collections") -@click.pass_context -def list(ctx, **kwargs): +@click.pass_obj +def list(obj, **kwargs): """ List policy collections in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="list-policy-collections", variables=kwargs)) + click.echo(_run_graphql(obj, name="list-policy-collections", variables=kwargs)) @policy_collection.command() @snippet_options("add-policy-collection") -@click.pass_context -def add(ctx, **kwargs): +@click.pass_obj +def add(obj, **kwargs): """ Add policy collection in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="add-policy-collection", variables=kwargs)) + click.echo(_run_graphql(obj, name="add-policy-collection", variables=kwargs)) @policy_collection.command() @snippet_options("show-policy-collection") -@click.pass_context -def show(ctx, **kwargs): +@click.pass_obj +def show(obj, **kwargs): """ Show policy collection in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="show-policy-collection", variables=kwargs)) + click.echo(_run_graphql(obj, name="show-policy-collection", variables=kwargs)) @policy_collection.command() @snippet_options("update-policy-collection") -@click.pass_context -def update(ctx, **kwargs): +@click.pass_obj +def update(obj, **kwargs): """ Update policy collection in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="update-policy-collection", variables=kwargs)) + click.echo(_run_graphql(obj, name="update-policy-collection", variables=kwargs)) @policy_collection.command() @snippet_options("add-policy-collection-item") -@click.pass_context -def add_item(ctx, **kwargs): +@click.pass_obj +def add_item(obj, **kwargs): """ Add item to policy collection in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="add-policy-collection-item", variables=kwargs)) + click.echo(_run_graphql(obj, name="add-policy-collection-item", variables=kwargs)) @policy_collection.command() @snippet_options("remove-policy-collection") -@click.pass_context -def remove(ctx, **kwargs): +@click.pass_obj +def remove(obj, **kwargs): """ Remove policy collection in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="remove-policy-collection", variables=kwargs)) + click.echo(_run_graphql(obj, name="remove-policy-collection", variables=kwargs)) @policy_collection.command() @snippet_options("remove-policy-collection-item") -@click.pass_context -def remove_item(ctx, **kwargs): +@click.pass_obj +def remove_item(obj, **kwargs): """ Remove item from a policy collection in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="remove-policy-collection-item", variables=kwargs)) + click.echo(_run_graphql(obj, name="remove-policy-collection-item", variables=kwargs)) diff --git a/stacklet/client/platform/commands/repository.py b/stacklet/client/platform/commands/repository.py index ceee4b0..46eb4e4 100644 --- a/stacklet/client/platform/commands/repository.py +++ b/stacklet/client/platform/commands/repository.py @@ -8,7 +8,7 @@ from stacklet.client.platform.exceptions import InvalidInputException from stacklet.client.platform.executor import StackletGraphqlExecutor, _run_graphql, snippet_options from stacklet.client.platform.graphql import StackletGraphqlSnippet -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @StackletGraphqlExecutor.registry.register("add-repository") @@ -147,7 +147,6 @@ class ScanRepositorySnippet(StackletGraphqlSnippet): @click.group(short_help="Run repository queries/mutations") @default_options() -@click.pass_context def repository(*args, **kwargs): """ Query against and Run mutations against Repository objects in Stacklet @@ -161,7 +160,6 @@ def repository(*args, **kwargs): $ stacklet repository --output json list """ - click_group_entry(*args, **kwargs) @StackletGraphqlExecutor.registry.register("show-repository") @@ -212,8 +210,8 @@ class ShowRepositorySnippet(StackletGraphqlSnippet): @repository.command() @snippet_options("add-repository") -@click.pass_context -def add(ctx, **kwargs): +@click.pass_obj +def add(obj, **kwargs): """ Add a Policy Repository to Stacklet """ @@ -224,54 +222,54 @@ def add(ctx, **kwargs): raise InvalidInputException("Both --auth-user and --ssh-private-key are required") with open(os.path.expanduser(private_key), "r") as f: kwargs["ssh_private_key"] = f.read().strip("\n") - click.echo(_run_graphql(ctx=ctx, name="add-repository", variables=kwargs)) + click.echo(_run_graphql(obj, name="add-repository", variables=kwargs)) @repository.command() @snippet_options("process-repository") -@click.pass_context -def process(ctx, **kwargs): +@click.pass_obj +def process(obj, **kwargs): """ Process a Policy Repository in Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="process-repository", variables=kwargs)) + click.echo(_run_graphql(obj, name="process-repository", variables=kwargs)) @repository.command() @snippet_options("list-repository") -@click.pass_context -def list(ctx, **kwargs): +@click.pass_obj +def list(obj, **kwargs): """ List repositories """ - click.echo(_run_graphql(ctx=ctx, name="list-repository", variables=kwargs)) + click.echo(_run_graphql(obj, name="list-repository", variables=kwargs)) @repository.command() @snippet_options("remove-repository") -@click.pass_context -def remove(ctx, **kwargs): +@click.pass_obj +def remove(obj, **kwargs): """ Remove a Policy Repository to Stacklet """ - click.echo(_run_graphql(ctx=ctx, name="remove-repository", variables=kwargs)) + click.echo(_run_graphql(obj, name="remove-repository", variables=kwargs)) @repository.command() @snippet_options("scan-repository") -@click.pass_context -def scan(ctx, **kwargs): +@click.pass_obj +def scan(obj, **kwargs): """ Scan a repository for policies """ - click.echo(_run_graphql(ctx=ctx, name="scan-repository", variables=kwargs)) + click.echo(_run_graphql(obj, name="scan-repository", variables=kwargs)) @repository.command() @snippet_options("show-repository") -@click.pass_context -def show(ctx, **kwargs): +@click.pass_obj +def show(obj, **kwargs): """ Show a repository """ - click.echo(_run_graphql(ctx=ctx, name="show-repository", variables=kwargs)) + click.echo(_run_graphql(obj, name="show-repository", variables=kwargs)) diff --git a/stacklet/client/platform/commands/user.py b/stacklet/client/platform/commands/user.py index bc11be2..18611c4 100644 --- a/stacklet/client/platform/commands/user.py +++ b/stacklet/client/platform/commands/user.py @@ -4,18 +4,15 @@ import click from stacklet.client.platform.cognito import CognitoUserManager -from stacklet.client.platform.context import StackletContext -from stacklet.client.platform.utils import click_group_entry, default_options +from stacklet.client.platform.utils import default_options @click.group(short_help="Run user queries/mutations") @default_options() -@click.pass_context def user(*args, **kwargs): """ Execute User Operations """ - click_group_entry(*args, **kwargs) @user.command() @@ -24,13 +21,12 @@ def user(*args, **kwargs): @click.option("--email") @click.option("--phone-number") @click.option("--permanent/--not-permanent", default=True) -@click.pass_context -def add(ctx, username, password, email=None, phone_number=None, permanent=True): +@click.pass_obj +def add(obj, username, password, email=None, phone_number=None, permanent=True): """ Add a cognito user in Stacklet """ - context = StackletContext(ctx.obj["config"], ctx.obj["raw_config"]) - manager = CognitoUserManager.from_context(context) + manager = CognitoUserManager.from_context(obj) manager.create_user( user=username, password=password, @@ -48,8 +44,7 @@ def ensure_group(ctx, username, group): """ Ensure that the specified user has the group if the group is available. """ - context = StackletContext(ctx.obj["config"], ctx.obj["raw_config"]) - manager = CognitoUserManager.from_context(context) + manager = CognitoUserManager.from_context(ctx.obj) success = manager.ensure_group( user=username, group=group, diff --git a/stacklet/client/platform/config.py b/stacklet/client/platform/config.py index 4970002..266b245 100644 --- a/stacklet/client/platform/config.py +++ b/stacklet/client/platform/config.py @@ -7,14 +7,22 @@ import json import os +import typing as t +from pathlib import Path from jsonschema import ValidationError, validate -from stacklet.client.platform.exceptions import ConfigValidationException +from .exceptions import ConfigValidationException MISSING = "missing" +DEFAULT_CONFIG_DIR = Path("~/.stacklet").expanduser() +DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.json" + +DEFAULT_OUTPUT_FORMAT = "yaml" + + class StackletConfig: schema = { "type": "object", @@ -47,16 +55,6 @@ def __init__( self.auth_url = auth_url self.cubejs = cubejs - if not all([self.api, self.cognito_user_pool_id, self.cognito_client_id, self.region]): - try: - path = "~/.stacklet/config.json" - if os.environ.get("STACKLET_CONFIG"): - path = os.environ["STACKLET_CONFIG"] - self = self.from_file(os.path.expanduser(path)) # noqa - except ValidationError: - raise ConfigValidationException - raise ConfigValidationException - def to_json(self): return dict( api=self.api, @@ -69,12 +67,57 @@ def to_json(self): ) @classmethod - def validate(cls, instance): - validate(instance=instance, schema=cls.schema) + def validate(cls, config): + try: + validate(instance=config, schema=cls.schema) + except ValidationError as err: + raise ConfigValidationException(str(err)) @classmethod def from_file(cls, filename): - with open(os.path.expanduser(filename), "r") as f: - res = json.load(f) - cls.validate(res) - return cls(**res) + path = Path(filename).expanduser() + if path.exists(): + with path.open() as f: + content = json.load(f) + else: + content = {} + + cls.validate(content) + return cls(**content) + + +JSONDict = dict[str, t.Any] + + +class StackletCredentials: + """Manage credentials files.""" + + def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None: + self.config_dir = config_dir + self._access_token_file = self.config_dir / "credentials" + self._id_token_file = self.config_dir / "id" + + def id_token(self) -> str | None: + """Return the ID token.""" + return self._read_file(self._id_token_file) + + def api_token(self) -> str | None: + """Return the API token. + + This can be the API key (if set in the environment), or the access + token. + """ + if token := os.getenv("STACKLET_API_KEY"): + return token + return self._read_file(self._access_token_file) + + def write(self, id_token: str, access_token: str) -> None: + """Write id and access tokens to file.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + self._id_token_file.write_text(id_token) + self._access_token_file.write_text(access_token) + + def _read_file(self, path: Path) -> str | None: + if not path.exists(): + return None + return path.read_text() diff --git a/stacklet/client/platform/context.py b/stacklet/client/platform/context.py index 8883157..8afd467 100644 --- a/stacklet/client/platform/context.py +++ b/stacklet/client/platform/context.py @@ -2,27 +2,35 @@ # SPDX-License-Identifier: Apache-2.0 import os +from typing import Any -from stacklet.client.platform.config import StackletConfig +from .config import DEFAULT_CONFIG_FILE, DEFAULT_OUTPUT_FORMAT, StackletConfig, StackletCredentials +from .formatter import Formatter class StackletContext: - """ - CLI Execution Context - """ - - DEFAULT_CONFIG = "~/.stacklet/config.json" - DEFAULT_CREDENTIALS = "~/.stacklet/credentials" - DEFAULT_ID = "~/.stacklet/id" - DEFAULT_OUTPUT = "yaml" - - def __init__(self, config=None, raw_config=None): - if len(raw_config.values()) != 0: + """CLI Execution Context.""" + + raw_config: dict[str, Any] + formatter: Formatter + config: StackletConfig + credentials: StackletCredentials + + def __init__( + self, + raw_config: dict | None = None, + config_file: str | None = None, + output_format: str = DEFAULT_OUTPUT_FORMAT, + ): + self.formatter = Formatter.registry.get(output_format) + self.credentials = StackletCredentials() + + if raw_config: self.config = StackletConfig(**raw_config) - elif config: - self.config = StackletConfig.from_file(config) else: - self.config = StackletConfig.from_file(self.DEFAULT_CONFIG) + if not config_file: + config_file = os.getenv("STACKLET_CONFIG", str(DEFAULT_CONFIG_FILE)) + self.config = StackletConfig.from_file(config_file) def can_sso_login(self): return all( @@ -31,15 +39,3 @@ def can_sso_login(self): self.config.cognito_client_id, ] ) - - -class StackletCredentialWriter: - def __init__(self, credentials, location=StackletContext.DEFAULT_CREDENTIALS): - self.credentials = credentials - self.location = location - - def __call__(self): - if not os.path.exists(os.path.dirname(os.path.expanduser(self.location))): - os.makedirs(os.path.dirname(os.path.expanduser(self.location))) - with open(os.path.expanduser(self.location), "w+") as f: - f.write(self.credentials) diff --git a/stacklet/client/platform/executor.py b/stacklet/client/platform/executor.py index 2b1eb89..bafcb3b 100644 --- a/stacklet/client/platform/executor.py +++ b/stacklet/client/platform/executor.py @@ -6,10 +6,9 @@ import requests -from stacklet.client.platform.context import StackletContext -from stacklet.client.platform.formatter import Formatter -from stacklet.client.platform.registry import PluginRegistry -from stacklet.client.platform.utils import _PAGINATION_OPTIONS, get_token, wrap_command +from .context import StackletContext +from .registry import PluginRegistry +from .utils import _PAGINATION_OPTIONS, wrap_command class StackletGraphqlExecutor: @@ -19,16 +18,15 @@ class StackletGraphqlExecutor: registry = PluginRegistry("StackletGraphqlSnippets") - def __init__(self, context, token): + def __init__(self, context: StackletContext): self.context = context self.api = self.context.config.api - self.token = token self.log = logging.getLogger("StackletGraphqlExecutor") self.session = requests.Session() self.session.headers.update( { - "Authorization": self.token, + "Authorization": context.credentials.api_token(), } ) @@ -58,10 +56,8 @@ def wrapper(func): return wrapper -def _run_graphql(ctx, name=None, variables=None, snippet=None, raw=False): - context = StackletContext(ctx.obj["config"], ctx.obj["raw_config"]) - token = get_token() - executor = StackletGraphqlExecutor(context, token) +def _run_graphql(context: StackletContext, name=None, variables=None, snippet=None, raw=False): + executor = StackletGraphqlExecutor(context) if variables is None: variables = {} @@ -82,5 +78,5 @@ def _run_graphql(ctx, name=None, variables=None, snippet=None, raw=False): res = executor.run(snippet=snippet, variables=variables) if raw: return res - fmt = Formatter.registry.get(ctx.obj["output"])() + fmt = context.formatter() return fmt(res) diff --git a/stacklet/client/platform/formatter.py b/stacklet/client/platform/formatter.py index 36607e4..91dfd97 100644 --- a/stacklet/client/platform/formatter.py +++ b/stacklet/client/platform/formatter.py @@ -12,12 +12,6 @@ class Formatter: registry = PluginRegistry("formats") -@Formatter.registry.register("") -class NullFormatter(Formatter): - def __call__(self, value): - raise NotImplementedError("Null Formatter") - - @Formatter.registry.register("plain") class RawFormatter(Formatter): def __call__(self, value): diff --git a/stacklet/client/platform/utils.py b/stacklet/client/platform/utils.py index f73be4b..9573871 100644 --- a/stacklet/client/platform/utils.py +++ b/stacklet/client/platform/utils.py @@ -2,18 +2,17 @@ # SPDX-License-Identifier: Apache-2.0 import logging -import os import click -from stacklet.client.platform.context import StackletContext -from stacklet.client.platform.formatter import Formatter +from .config import DEFAULT_CONFIG_FILE, DEFAULT_OUTPUT_FORMAT +from .formatter import Formatter _DEFAULT_OPTIONS = { - "config": {"default": "", "help": ""}, + "config": {"default": DEFAULT_CONFIG_FILE, "help": "Configuration file"}, "output": { "type": click.Choice(list(Formatter.registry.keys()), case_sensitive=False), - "default": "", + "default": DEFAULT_OUTPUT_FORMAT, "help": "Output type", }, "cognito_user_pool_id": { @@ -90,12 +89,6 @@ def wrap_command(func, options, required=False, prompt=False): return func -def get_token(): - with open(os.path.expanduser(StackletContext.DEFAULT_CREDENTIALS), "r") as f: - token = f.read() - return token - - def default_options(*args, **kwargs): def wrapper(func): wrap_command(func, _DEFAULT_OPTIONS, required=False, prompt=False) @@ -114,44 +107,12 @@ def get_log_level(verbose): return level -def click_group_entry( - ctx, - config, - output, - cognito_user_pool_id, - cognito_client_id, - cognito_region, - api, - v, -): +def setup_logging(level): logging.basicConfig() # Don't make botocore or urllib3 more verbose logging.getLogger("botocore").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) root_handler = logging.getLogger() - if v != 0: - root_handler.setLevel(level=get_log_level(v)) - - ctx.ensure_object(dict) - config_items = [cognito_user_pool_id, cognito_client_id, cognito_region, api] - if any(config_items) and not all(config_items): - raise Exception( - "All options must be set for config items: --cognito-user-pool-id, " - + "--cognito-client-id, --cognito-region, and --api" - ) - # inherit the parent's configs if they exist - ctx.obj.setdefault("config", ctx.obj.get("config", StackletContext.DEFAULT_CONFIG)) - ctx.obj.setdefault("output", ctx.obj.get("output", StackletContext.DEFAULT_OUTPUT)) - ctx.obj.setdefault("raw_config", ctx.obj.get("raw_config", {})) - if config: - ctx.obj["config"] = config - if output: - ctx.obj["output"] = output - if all(config_items): - ctx.obj["raw_config"] = { - "cognito_user_pool_id": cognito_user_pool_id, - "cognito_client_id": cognito_client_id, - "region": cognito_region, - "api": api, - } + if level: + root_handler.setLevel(level=get_log_level(level)) diff --git a/stacklet/client/platform/vendored/auth.py b/stacklet/client/platform/vendored/auth.py index 97cb1f1..c5f6c72 100644 --- a/stacklet/client/platform/vendored/auth.py +++ b/stacklet/client/platform/vendored/auth.py @@ -39,7 +39,7 @@ import click -from stacklet.client.platform.context import StackletContext, StackletCredentialWriter +from ..config import StackletCredentials class ClientRedirectServer(http.server.HTTPServer): @@ -78,8 +78,7 @@ def do_GET(self): def do_POST(self): res = self.get_request_body() res = json.loads(res) - StackletCredentialWriter(res["access_token"])() - StackletCredentialWriter(res["id_token"], StackletContext.DEFAULT_ID)() + StackletCredentials().write(res["id_token"], res["access_token"]) self.send_response(200) self.server.completed = True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c019666 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +from typing import Iterator +from unittest.mock import patch + +import pytest +import requests_mock + +from stacklet.client.platform.context import StackletContext +from stacklet.client.platform.executor import StackletGraphqlExecutor + + +@pytest.fixture +def sample_config(): + return { + "cognito_user_pool_id": "foo", + "cognito_client_id": "bar", + "region": "us-east-1", + "api": "mock://stacklet.acme.org/api", + } + + +@pytest.fixture +def mock_api_token() -> Iterator[str]: + token = "mock-token" + with patch("stacklet.client.platform.config.StackletCredentials.api_token", return_value=token): + yield token + + +@pytest.fixture(autouse=True) +def requests_adapter() -> Iterator[requests_mock.Adapter]: + """Automatically mock all HTTP requests in tests.""" + with requests_mock.Mocker(real_http=False) as adapter: + yield adapter + + +@pytest.fixture +def config_file(tmp_path): + return tmp_path / "config.json" + + +@pytest.fixture +def context(mock_api_token, sample_config, config_file) -> StackletContext: + return StackletContext(raw_config=sample_config, config_file=str(config_file)) + + +@pytest.fixture +def executor(context) -> StackletGraphqlExecutor: + return StackletGraphqlExecutor(context) diff --git a/tests/test_cli.py b/tests/test_cli.py index b987d7c..5c71cfb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,12 +7,11 @@ from tempfile import NamedTemporaryFile import requests -import requests_mock from .utils import BaseCliTest -class AdminCliTest(BaseCliTest): +class TestAdminCli(BaseCliTest): def test_cli_health_check(self): res = self.runner.invoke(self.cli, ["--help"]) self.assertEqual(res.exit_code, 0) @@ -85,33 +84,30 @@ def test_auto_configure_url_only(self): "saml_providers": [{"name": "TestIDP", "idp_id": "test-idp-id"}], } - with requests_mock.Mocker() as m: - # Mock both config endpoints - m.get("https://console.dev.stacklet.dev/config/cognito.json", json=mock_config) - m.get("https://console.dev.stacklet.dev/config/cubejs.json", json={}) - - res = self.runner.invoke( - self.cli, - [ - "auto-configure", - "--url=console.dev.stacklet.dev", - f"--location={file_location.name}", - ], - ) + self.adapter.get("https://console.dev.stacklet.dev/config/cognito.json", json=mock_config) + self.adapter.get("https://console.dev.stacklet.dev/config/cubejs.json", json={}) - self.assertEqual(res.exit_code, 0) + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + "--url=console.dev.stacklet.dev", + f"--location={file_location.name}", + ], + ) - # Verify the config was saved correctly - with open(file_location.name, "r") as f: - config = json.load(f) + self.assertEqual(res.exit_code, 0) + + with open(file_location.name, "r") as f: + config = json.load(f) - self.assertEqual(config["api"], "https://api.dev.stacklet.dev") - self.assertEqual(config["auth_url"], "https://auth.console.dev.stacklet.dev") - self.assertEqual(config["cognito_client_id"], "test-client-id") - self.assertEqual(config["cognito_user_pool_id"], "us-east-2_test123") - self.assertEqual(config["region"], "us-east-2") - self.assertEqual(config["cubejs"], "https://cubejs.dev.stacklet.dev") - self.assertEqual(config["idp_id"], "test-idp-id") + self.assertEqual(config["api"], "https://api.dev.stacklet.dev") + self.assertEqual(config["auth_url"], "https://auth.console.dev.stacklet.dev") + self.assertEqual(config["cognito_client_id"], "test-client-id") + self.assertEqual(config["cognito_user_pool_id"], "us-east-2_test123") + self.assertEqual(config["region"], "us-east-2") + self.assertEqual(config["cubejs"], "https://cubejs.dev.stacklet.dev") + self.assertEqual(config["idp_id"], "test-idp-id") os.unlink(file_location.name) @@ -128,27 +124,26 @@ def test_auto_configure_prefix_only(self): "saml_providers": [{"name": "OrgIDP", "idp_id": "org-idp-id"}], } - with requests_mock.Mocker() as m: - m.get("https://console.myorg.stacklet.io/config/cognito.json", json=mock_config) - m.get("https://console.myorg.stacklet.io/config/cubejs.json", json={}) - - res = self.runner.invoke( - self.cli, - [ - "auto-configure", - "--prefix=myorg", - f"--location={file_location.name}", - ], - ) + self.adapter.get("https://console.myorg.stacklet.io/config/cognito.json", json=mock_config) + self.adapter.get("https://console.myorg.stacklet.io/config/cubejs.json", json={}) + + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + "--prefix=myorg", + f"--location={file_location.name}", + ], + ) - self.assertEqual(res.exit_code, 0) + self.assertEqual(res.exit_code, 0) - with open(file_location.name, "r") as f: - config = json.load(f) + with open(file_location.name, "r") as f: + config = json.load(f) - self.assertEqual(config["api"], "https://api.myorg.stacklet.io") - self.assertEqual(config["auth_url"], "https://auth.console.myorg.stacklet.io") - self.assertEqual(config["cubejs"], "https://cubejs.myorg.stacklet.io") + self.assertEqual(config["api"], "https://api.myorg.stacklet.io") + self.assertEqual(config["auth_url"], "https://auth.console.myorg.stacklet.io") + self.assertEqual(config["cubejs"], "https://cubejs.myorg.stacklet.io") os.unlink(file_location.name) @@ -165,27 +160,27 @@ def test_auto_configure_url_without_console_prefix(self): "saml_providers": [{"name": "ExampleIDP", "idp_id": "example-idp-id"}], } - with requests_mock.Mocker() as m: - m.get("https://console.example.stacklet.io/config/cognito.json", json=mock_config) - m.get("https://console.example.stacklet.io/config/cubejs.json", json={}) - - res = self.runner.invoke( - self.cli, - [ - "auto-configure", - "--url=example.stacklet.io", # Missing console prefix - f"--location={file_location.name}", - ], - ) + self.adapter.get( + "https://console.example.stacklet.io/config/cognito.json", json=mock_config + ) + self.adapter.get("https://console.example.stacklet.io/config/cubejs.json", json={}) + + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + "--url=example.stacklet.io", # Missing console prefix + f"--location={file_location.name}", + ], + ) - self.assertEqual(res.exit_code, 0) + self.assertEqual(res.exit_code, 0) - # Verify it added the console prefix and connected correctly - with open(file_location.name, "r") as f: - config = json.load(f) + with open(file_location.name, "r") as f: + config = json.load(f) - self.assertEqual(config["region"], "eu-west-1") - self.assertEqual(config["cognito_user_pool_id"], "eu-west-1_test789") + self.assertEqual(config["region"], "eu-west-1") + self.assertEqual(config["cognito_user_pool_id"], "eu-west-1_test789") os.unlink(file_location.name) @@ -219,24 +214,22 @@ def test_auto_configure_connection_error(self): """Test auto-configure behavior when connection fails""" file_location = NamedTemporaryFile(mode="w+", delete=False) - with requests_mock.Mocker() as m: - # Mock connection error - m.get( - "https://console.nonexistent.stacklet.dev/config/cognito.json", - exc=requests.exceptions.ConnectionError, - ) + self.adapter.get( + "https://console.nonexistent.stacklet.dev/config/cognito.json", + exc=requests.exceptions.ConnectionError, + ) - res = self.runner.invoke( - self.cli, - [ - "auto-configure", - "--url=console.nonexistent.stacklet.dev", - f"--location={file_location.name}", - ], - ) + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + "--url=console.nonexistent.stacklet.dev", + f"--location={file_location.name}", + ], + ) - self.assertEqual(res.exit_code, 1) # Command should exit with error code - self.assertIn("Unable to connect to", res.output) + self.assertEqual(res.exit_code, 1) + self.assertIn("Unable to connect to", res.output) os.unlink(file_location.name) @@ -256,26 +249,25 @@ def test_auto_configure_multiple_idps_with_selection(self): ], } - with requests_mock.Mocker() as m: - m.get("https://console.multi.stacklet.dev/config/cognito.json", json=mock_config) - m.get("https://console.multi.stacklet.dev/config/cubejs.json", json={}) - - res = self.runner.invoke( - self.cli, - [ - "auto-configure", - "--url=console.multi.stacklet.dev", - "--idp=IDP2", - f"--location={file_location.name}", - ], - ) + self.adapter.get("https://console.multi.stacklet.dev/config/cognito.json", json=mock_config) + self.adapter.get("https://console.multi.stacklet.dev/config/cubejs.json", json={}) + + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + "--url=console.multi.stacklet.dev", + "--idp=IDP2", + f"--location={file_location.name}", + ], + ) - self.assertEqual(res.exit_code, 0) + self.assertEqual(res.exit_code, 0) - with open(file_location.name, "r") as f: - config = json.load(f) + with open(file_location.name, "r") as f: + config = json.load(f) - self.assertEqual(config["idp_id"], "idp-2") # Should use the selected IDP + self.assertEqual(config["idp_id"], "idp-2") os.unlink(file_location.name) @@ -293,18 +285,16 @@ def test_auto_configure_multiple_idps_no_selection_error(self): ], } - with requests_mock.Mocker() as m: - m.get("https://console.multi.stacklet.dev/config/cognito.json", json=mock_config) - m.get("https://console.multi.stacklet.dev/config/cubejs.json", json={}) - - res = self.runner.invoke( - self.cli, - [ - "auto-configure", - "--url=console.multi.stacklet.dev", - # No --idp specified - ], - ) + self.adapter.get("https://console.multi.stacklet.dev/config/cognito.json", json=mock_config) + self.adapter.get("https://console.multi.stacklet.dev/config/cubejs.json", json={}) + + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + "--url=console.multi.stacklet.dev", + ], + ) - self.assertEqual(res.exit_code, 1) # Command should exit with error code - self.assertIn("Multiple identity providers available", res.output) + self.assertEqual(res.exit_code, 1) + self.assertIn("Multiple identity providers available", res.output) diff --git a/tests/test_client.py b/tests/test_client.py index 277e44e..094ed7d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,16 +1,19 @@ # Copyright Stacklet, Inc. # SPDX-License-Identifier: Apache-2.0 +import json from unittest.mock import patch from stacklet.client.platform.client import platform_client -@patch("stacklet.client.platform.client.get_token") @patch("stacklet.client.platform.client.StackletContext") -@patch("stacklet.client.platform.client.Path") -def test_client_loaded_commands(patched_path, patched_stacklet_context, patched_get_token): - client = platform_client() +def test_client_loaded_commands(_, config_file, sample_config): + # write the config in the config file + config_file.write_text(json.dumps(sample_config)) + + with patch("stacklet.client.platform.client.DEFAULT_CONFIG_FILE", config_file): + client = platform_client() assert hasattr(client, "list_bindings") assert hasattr(client, "list_accounts") assert hasattr(client, "list_repository") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..231e3a6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,47 @@ +import os +from contextlib import contextmanager +from unittest.mock import patch + +import pytest + +from stacklet.client.platform.config import StackletCredentials + + +@pytest.fixture +def creds(tmp_path_factory) -> StackletCredentials: + return StackletCredentials(tmp_path_factory.mktemp("config")) + + +class TestStackletCredentials: + @contextmanager + def env_api_key(self, key): + with patch.dict(os.environ, {"STACKLET_API_KEY": key}): + yield + + def test_write(self, creds): + creds.write("id token", "access token") + assert (creds.config_dir / "id").read_text() == "id token" + assert (creds.config_dir / "credentials").read_text() == "access token" + + def test_id_token_missing(self, creds): + assert creds.id_token() is None + + def test_id_token(self, creds): + creds.write("id token", "access token") + assert creds.id_token() == "id token" + + def test_api_token_missing(self, creds): + assert creds.api_token() is None + + def test_api_token_from_file(self, creds): + creds.write("id token", "access token") + assert creds.api_token() == "access token" + + def test_api_token_from_env(self, creds): + with self.env_api_key("a-b-c"): + assert creds.api_token() == "a-b-c" + + def test_api_token_prefer_env(self, creds): + creds.write("id token", "access token") + with self.env_api_key("a-b-c"): + assert creds.api_token() == "a-b-c" diff --git a/tests/test_graphql.py b/tests/test_graphql.py index 4674786..965304f 100644 --- a/tests/test_graphql.py +++ b/tests/test_graphql.py @@ -1,22 +1,18 @@ # Copyright Stacklet, Inc. # SPDX-License-Identifier: Apache-2.0 -import json -from unittest.mock import patch - +import requests_mock import yaml -from .utils import BaseCliTest, get_executor_adapter +from .utils import BaseCliTest class GraphqlTest(BaseCliTest): def test_executor_run(self): - executor, adapter = get_executor_adapter() - self.assertEqual(executor.token, "foo") - self.assertEqual(executor.api, "mock://stacklet.acme.org/api") - self.assertEqual(executor.session.headers["authorization"], "foo") + self.assertEqual(self.executor.api, "mock://stacklet.acme.org/api") + self.assertEqual(self.executor.session.headers["authorization"], "mock-token") - adapter.register_uri( + self.adapter.register_uri( "POST", "mock://stacklet.acme.org/api", json={ @@ -39,9 +35,9 @@ def test_executor_run(self): }, ) - snippet = executor.registry.get("list-accounts") + snippet = self.executor.registry.get("list-accounts") - results = executor.run( + results = self.executor.run( snippet, variables={ "provider": "AWS", @@ -75,34 +71,21 @@ def test_executor_run(self): ) def test_graphql_executor_via_cli(self): - executor, adapter = get_executor_adapter() - snippet = '{ platform { dashboardDefinition(name:"cis-v140") } }' - adapter.register_uri( - "POST", - "mock://stacklet.acme.org/api", - json={"data": {"platform": {"version": "1.2.3+git.abcdef0"}}}, - ) - - with patch("stacklet.client.platform.executor.requests.Session", autospec=True) as patched: - with patch("stacklet.client.platform.executor.get_token", return_value="foo"): - patched.return_value = executor.session - res = self.runner.invoke( - self.cli, - [ - "graphql", - "--api=mock://stacklet.acme.org/api", - "--cognito-region=us-east-1", - "--cognito-user-pool-id=foo", - "--cognito-client-id=bar", - "run", - f"--snippet={snippet}", - ], - ) - body = json.loads(adapter.last_request.body.decode("utf-8")) - assert body == {"query": snippet} - assert res.exit_code == 0 - assert yaml.safe_load(res.output) == { - "data": {"platform": {"version": "1.2.3+git.abcdef0"}} - } + payload = {"data": {"platform": {"version": "1.2.3+git.abcdef0"}}} + self.adapter.post(requests_mock.ANY, json=payload) + res = self.runner.invoke( + self.cli, + [ + "--api=mock://stacklet.acme.org/api", + "--cognito-region=us-east-1", + "--cognito-user-pool-id=foo", + "--cognito-client-id=bar", + "graphql", + "run", + f"--snippet={snippet}", + ], + ) + assert res.exit_code == 0, f"CLI failed: {res.output}" + assert yaml.safe_load(res.output) == payload diff --git a/tests/test_repository.py b/tests/test_repository.py index 96db4dd..5da816c 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -3,36 +3,27 @@ import json from textwrap import dedent -from typing import Optional -from unittest.mock import patch -from click.testing import Result - -from .utils import BaseCliTest, JSONDict, get_executor_adapter +from .utils import BaseCliTest, JSONDict class RepositoryTest(BaseCliTest): - def run_query( - self, args: list[str], response: Optional[JSONDict] = None - ) -> tuple[Result, JSONDict]: - if response is None: - response = JSONDict( - data={ - "addRepository": { - "url": "mock://git.acme.org/stacklet/policies.git", - "name": "test-policies", - } - } - ) - return super().run_query("repository", args, response) - def test_add_repository(self): res, body = self.run_query( + "repository", [ "add", "--url=mock://git.acme.org/stacklet/policies.git", "--name=test-policies", - ] + ], + response=JSONDict( + data={ + "addRepository": { + "url": "mock://git.acme.org/stacklet/policies.git", + "name": "test-policies", + } + } + ), ) assert res.output == dedent( """\ @@ -63,12 +54,21 @@ def test_add_repository(self): def test_add_repository_deep(self): res, body = self.run_query( + "repository", [ "add", "--url=mock://git.acme.org/stacklet/policies.git", "--name=test-policies", "--deep-import=true", - ] + ], + response=JSONDict( + data={ + "addRepository": { + "url": "mock://git.acme.org/stacklet/policies.git", + "name": "test-policies", + } + } + ), ) assert res.output == dedent( """\ @@ -105,37 +105,32 @@ def test_add_repository_deep(self): } def test_process_repository(self): - executor, adapter = get_executor_adapter() - adapter.register_uri( + self.adapter.register_uri( "POST", "mock://stacklet.acme.org/api", json={"data": {"processRepository": "34c10c3e-d841-4e63-9d51-01b92f36c502"}}, ) - - with patch("stacklet.client.platform.executor.requests.Session", autospec=True) as patched: - with patch("stacklet.client.platform.executor.get_token", return_value="foo"): - patched.return_value = executor.session - res = self.runner.invoke( - self.cli, - [ - "repository", - "--api=mock://stacklet.acme.org/api", - "--cognito-region=us-east-1", - "--cognito-user-pool-id=foo", - "--cognito-client-id=bar", - "process", - "--url=mock://git.acme.org/stacklet/policies.git", - ], - ) - self.assertEqual(res.exit_code, 0) - self.assertEqual( - res.output, - "data:\n processRepository: 34c10c3e-d841-4e63-9d51-01b92f36c502\n\n", - ) - body = json.loads(adapter.last_request.body.decode("utf-8")) - self.assertEqual( - body["query"].strip(), - """mutation ($url: String!) { + res = self.runner.invoke( + self.cli, + [ + "--api=mock://stacklet.acme.org/api", + "--cognito-region=us-east-1", + "--cognito-user-pool-id=foo", + "--cognito-client-id=bar", + "repository", + "process", + "--url=mock://git.acme.org/stacklet/policies.git", + ], + ) + self.assertEqual(res.exit_code, 0) + self.assertEqual( + res.output, + "data:\n processRepository: 34c10c3e-d841-4e63-9d51-01b92f36c502\n\n", + ) + body = json.loads(self.adapter.last_request.body.decode("utf-8")) + self.assertEqual( + body["query"].strip(), + """mutation ($url: String!) { processRepository(input:{url: $url}) }""", - ) + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index ee977e6..af9886a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,14 +4,13 @@ import os from tempfile import NamedTemporaryFile from unittest import TestCase -from unittest.mock import patch from stacklet.client.platform.formatter import ( JsonFormatter, RawFormatter, YamlFormatter, ) -from stacklet.client.platform.utils import get_log_level, get_token +from stacklet.client.platform.utils import get_log_level class UtilsTest(TestCase): @@ -23,12 +22,6 @@ def setUp(self): def tearDown(self): os.unlink(self.credential_file.name) - @patch("stacklet.client.platform.utils.StackletContext") - def test_get_token(self, patched_context): - patched_context.DEFAULT_CREDENTIALS = self.credential_file.name - token = get_token() - self.assertEqual(token, "foo") - def test_get_log_level(self): self.assertEqual(get_log_level(-9999), 50) self.assertEqual(get_log_level(0), 40) diff --git a/tests/utils.py b/tests/utils.py index 5b6e5d4..0c929cb 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,78 +4,46 @@ import json from typing import Any from unittest import TestCase -from unittest.mock import MagicMock, patch -import requests_mock +import pytest from click.testing import CliRunner, Result from stacklet.client.platform.cli import cli -from stacklet.client.platform.context import StackletContext -from stacklet.client.platform.executor import StackletGraphqlExecutor JSONDict = dict[str, Any] -def get_mock_context(): - mock_ctx = MagicMock() - mock_ctx.config = { - "config": None, - "output": "yaml", - "page_variables": {"first": 20, "last": 20, "before": "", "after": ""}, - "raw_config": { - "cognito_user_pool_id": "foo", - "cognito_client_id": "bar", - "region": "us-east-1", - "api": "https://stacklet.acme.org/api", - }, - } - return mock_ctx - - -def get_executor_adapter(): - context = StackletContext( - raw_config={ - "cognito_user_pool_id": "foo", - "cognito_client_id": "bar", - "region": "us-east-1", - "api": "mock://stacklet.acme.org/api", - } - ) - executor = StackletGraphqlExecutor(context=context, token="foo") - adapter = requests_mock.Adapter() - executor.session.mount("mock://", adapter) - return executor, adapter - - class BaseCliTest(TestCase): """ Base Test Class for Cli Tests """ - runner = CliRunner() - cli = cli + @pytest.fixture(autouse=True) + def _setup(self, requests_adapter, executor): + self.runner = CliRunner() + self.cli = cli + self.adapter = requests_adapter + self.executor = executor def run_query( self, base_command: str, args: list[str], response: JSONDict ) -> tuple[Result, JSONDict]: - executor, adapter = get_executor_adapter() - adapter.register_uri( + self.adapter.register_uri( "POST", "mock://stacklet.acme.org/api", json=response, ) - with patch("stacklet.client.platform.executor.requests.Session", autospec=True) as patched: - with patch("stacklet.client.platform.executor.get_token", return_value="foo"): - patched.return_value = executor.session - cli_args = [ - base_command, - "--api=mock://stacklet.acme.org/api", - "--cognito-region=us-east-1", - "--cognito-user-pool-id=foo", - "--cognito-client-id=bar", - ] + args - - res = self.runner.invoke(self.cli, cli_args) # type: ignore - assert res.exit_code == 0, res.stderr - return res, json.loads(adapter.last_request.body.decode("utf-8")) # type: ignore + cli_args = [ + "--api=mock://stacklet.acme.org/api", + "--cognito-region=us-east-1", + "--cognito-user-pool-id=foo", + "--cognito-client-id=bar", + base_command, + ] + args + + res = self.runner.invoke(self.cli, cli_args) # type: ignore + assert res.exit_code == 0, ( + f"stderr: {res.stderr}\nstdout: {res.output}\nexception: {res.exception}" + ) + return res, json.loads(self.adapter.last_request.body.decode("utf-8")) # type: ignore