diff --git a/stacklet/client/platform/cli.py b/stacklet/client/platform/cli.py index 8093b7a..767be79 100644 --- a/stacklet/client/platform/cli.py +++ b/stacklet/client/platform/cli.py @@ -128,9 +128,11 @@ def configure( @cli.command() @click.option( "--url", + help="A Stacklet console URL", +) +@click.option( "--prefix", - required=True, - help="A Stacklet console URL or deployment prefix", + help="A deployment prefix (assumes hosting at .stacklet.io)", ) @click.option( "--idp", @@ -142,7 +144,7 @@ def configure( type=click.Path(path_type=pathlib.Path, dir_okay=False), show_default=True, ) -def auto_configure(url, idp, location): +def auto_configure(url, prefix, idp, location): """Automatically configure the stacklet-admin CLI Fetch configuration details from a live Stacklet instance and use it @@ -163,23 +165,36 @@ def auto_configure(url, idp, location): # Using a deployment prefix (assumes hosting at .stacklet.io) > stacklet-admin auto-configure --prefix myorg """ - parts = urlsplit(url, scheme="https", allow_fragments=False) - host = parts.netloc or parts.path + # Validate mutual exclusion + if url and prefix: + raise click.ClickException( + "Cannot specify both --url and --prefix. Please provide one or the other." + ) + + if not url and not prefix: + raise click.ClickException("Must specify either --url or --prefix.") - if "." not in host: - # Assume that we have a prefix for a Stacklet instance - # hosted under stacklet.io - host = f"console.{host}.stacklet.io" - if not host.startswith("console"): - # Be forgiving if we get a base URL like customer.stacklet.io - host = f"console.{host}" + # Determine the host based on input type + if prefix: + # Handle prefix: always generate .stacklet.io URL + host = f"console.{prefix}.stacklet.io" + scheme = "https" + else: + # Handle URL: parse and potentially add console prefix + parts = urlsplit(url, scheme="https", allow_fragments=False) + host = parts.netloc or parts.path + scheme = parts.scheme + + if not host.startswith("console"): + # Be forgiving if we get a base URL like customer.stacklet.io + host = f"console.{host}" config = {} try: for config_path in ("config/cognito.json", "config/cubejs.json"): config_url = urlunsplit( ( - parts.scheme, + scheme, host, config_path, None, @@ -190,17 +205,15 @@ def auto_configure(url, idp, location): response.raise_for_status() config.update(response.json()) except requests.exceptions.ConnectionError as err: - click.echo(f"Unable to connect to {config_url}") - click.echo(err) - return + raise click.ClickException(f"Unable to connect to {config_url}\n{err}") except requests.exceptions.HTTPError as err: - click.echo(f"Unable to retrieve configuration details from {config_url}") - click.echo(err) - return + raise click.ClickException( + f"Unable to retrieve configuration details from {config_url}\n{err}" + ) except requests.exceptions.JSONDecodeError as err: - click.echo(f"Unable to parse configuration details from {config_url}") - click.echo(err) - return + raise click.ClickException( + f"Unable to parse configuration details from {config_url}\n{err}" + ) try: auth_url = f"https://{config['cognito_install']}" @@ -223,23 +236,19 @@ def auto_configure(url, idp, location): _, idp_id = name_to_id.popitem() else: if not idp: - click.echo( + raise click.ClickException( "Multiple identity providers available, specify one with --idp: " + ", ".join(sorted(name_to_id)) ) - return idp_id = name_to_id.get(idp) if not idp_id: - click.echo( + raise click.ClickException( f"Unknown identity provider '{idp}', known names: " + ", ".join(sorted(name_to_id)) ) - return formatted_config["idp_id"] = idp_id except KeyError as err: - click.echo("The configuration details are missing a required key") - click.echo(err) - return + raise click.ClickException(f"The configuration details are missing a required key: {err}") config_file = location.expanduser() if not config_file.exists(): diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f65960..b987d7c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,9 @@ import textwrap from tempfile import NamedTemporaryFile +import requests +import requests_mock + from .utils import BaseCliTest @@ -67,3 +70,241 @@ def test_admin_save_config_and_show(self): ) os.unlink(file_location.name) + + def test_auto_configure_url_only(self): + """Test auto-configure with --url parameter""" + file_location = NamedTemporaryFile(mode="w+", delete=False) + + # Mock the HTTP responses for config endpoints + mock_config = { + "cognito_install": "auth.console.dev.stacklet.dev", + "cognito_user_pool_region": "us-east-2", + "cognito_user_pool_id": "us-east-2_test123", + "cognito_user_pool_client_id": "test-client-id", + "cubejs_domain": "cubejs.dev.stacklet.dev", + "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.assertEqual(res.exit_code, 0) + + # Verify the config was saved correctly + 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") + + os.unlink(file_location.name) + + def test_auto_configure_prefix_only(self): + """Test auto-configure with --prefix parameter""" + file_location = NamedTemporaryFile(mode="w+", delete=False) + + mock_config = { + "cognito_install": "auth.console.myorg.stacklet.io", + "cognito_user_pool_region": "us-west-2", + "cognito_user_pool_id": "us-west-2_test456", + "cognito_user_pool_client_id": "test-client-id-2", + "cubejs_domain": "cubejs.myorg.stacklet.io", + "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.assertEqual(res.exit_code, 0) + + 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") + + os.unlink(file_location.name) + + def test_auto_configure_url_without_console_prefix(self): + """Test auto-configure with URL that needs console prefix added""" + file_location = NamedTemporaryFile(mode="w+", delete=False) + + mock_config = { + "cognito_install": "auth.console.example.stacklet.io", + "cognito_user_pool_region": "eu-west-1", + "cognito_user_pool_id": "eu-west-1_test789", + "cognito_user_pool_client_id": "test-client-id-3", + "cubejs_domain": "cubejs.example.stacklet.io", + "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.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) + + self.assertEqual(config["region"], "eu-west-1") + self.assertEqual(config["cognito_user_pool_id"], "eu-west-1_test789") + + os.unlink(file_location.name) + + def test_auto_configure_both_url_and_prefix_error(self): + """Test that providing both --url and --prefix results in an error""" + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + "--url=console.dev.stacklet.dev", + "--prefix=dev", + ], + ) + + self.assertEqual(res.exit_code, 1) # Command should exit with error code + self.assertIn("Cannot specify both --url and --prefix", res.output) + + def test_auto_configure_neither_url_nor_prefix_error(self): + """Test that providing neither --url nor --prefix results in an error""" + res = self.runner.invoke( + self.cli, + [ + "auto-configure", + ], + ) + + self.assertEqual(res.exit_code, 1) # Command should exit with error code + self.assertIn("Must specify either --url or --prefix", res.output) + + 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, + ) + + 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) + + os.unlink(file_location.name) + + def test_auto_configure_multiple_idps_with_selection(self): + """Test auto-configure with multiple IDPs and explicit selection""" + file_location = NamedTemporaryFile(mode="w+", delete=False) + + mock_config = { + "cognito_install": "auth.console.multi.stacklet.dev", + "cognito_user_pool_region": "us-east-1", + "cognito_user_pool_id": "us-east-1_multi123", + "cognito_user_pool_client_id": "multi-client-id", + "cubejs_domain": "cubejs.multi.stacklet.dev", + "saml_providers": [ + {"name": "IDP1", "idp_id": "idp-1"}, + {"name": "IDP2", "idp_id": "idp-2"}, + ], + } + + 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.assertEqual(res.exit_code, 0) + + with open(file_location.name, "r") as f: + config = json.load(f) + + self.assertEqual(config["idp_id"], "idp-2") # Should use the selected IDP + + os.unlink(file_location.name) + + def test_auto_configure_multiple_idps_no_selection_error(self): + """Test auto-configure with multiple IDPs but no selection results in error""" + mock_config = { + "cognito_install": "auth.console.multi.stacklet.dev", + "cognito_user_pool_region": "us-east-1", + "cognito_user_pool_id": "us-east-1_multi123", + "cognito_user_pool_client_id": "multi-client-id", + "cubejs_domain": "cubejs.multi.stacklet.dev", + "saml_providers": [ + {"name": "IDP1", "idp_id": "idp-1"}, + {"name": "IDP2", "idp_id": "idp-2"}, + ], + } + + 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.assertEqual(res.exit_code, 1) # Command should exit with error code + self.assertIn("Multiple identity providers available", res.output)