diff --git a/data_safe_haven/commands/shm.py b/data_safe_haven/commands/shm.py index 522609b8ff..c25012d46d 100644 --- a/data_safe_haven/commands/shm.py +++ b/data_safe_haven/commands/shm.py @@ -140,9 +140,21 @@ def teardown() -> None: # Teardown Data Safe Haven SHM infrastructure. try: - config = SHMConfig.from_remote(context) - shm_infra = ImperativeSHM(context, config) - shm_infra.teardown() + if SHMConfig.remote_exists(context): + config = SHMConfig.from_remote(context) + shm_infra = ImperativeSHM(context, config) + console.print( + "Tearing down the Safe Haven Management environment will permanently delete all associated resources, including remotely stored configurations." + ) + if not console.confirm( + "Do you wish to continue tearing down the SHM?", default_to_yes=False + ): + console.print("SHM teardown cancelled by user.") + raise typer.Exit(0) + shm_infra.teardown() + else: + logger.critical(f"No deployed SHM found for context [green]{context.name}.") + raise typer.Exit(1) except DataSafeHavenError as exc: logger.critical("Could not teardown Safe Haven Management environment.") raise typer.Exit(1) from exc diff --git a/data_safe_haven/commands/sre.py b/data_safe_haven/commands/sre.py index e7b781cdbf..8c3e0b5cdc 100644 --- a/data_safe_haven/commands/sre.py +++ b/data_safe_haven/commands/sre.py @@ -4,6 +4,7 @@ import typer +from data_safe_haven import console from data_safe_haven.config import ContextManager, DSHPulumiConfig, SHMConfig, SREConfig from data_safe_haven.exceptions import DataSafeHavenConfigError, DataSafeHavenError from data_safe_haven.external import AzureSdk, GraphApi @@ -190,6 +191,17 @@ def teardown( pulumi_config = DSHPulumiConfig.from_remote(context) sre_config = SREConfig.from_remote_by_name(context, name) + console.print( + "Tearing down the Secure Research Environment will permanently delete all associated resources, " + "including all data stored in the environment.\n" + "Ensure that any desired outputs have been extracted before continuing." + ) + if not console.confirm( + "Do you wish to continue tearing down the SRE?", default_to_yes=False + ): + console.print("SRE teardown cancelled by user.") + raise typer.Exit(0) + # Check whether current IP address is authorised to take administrator actions if not ip_address_in_list(sre_config.sre.admin_ip_addresses): logger.warning( diff --git a/data_safe_haven/infrastructure/programs/imperative_shm.py b/data_safe_haven/infrastructure/programs/imperative_shm.py index d08fe891b4..f233c7c7fe 100644 --- a/data_safe_haven/infrastructure/programs/imperative_shm.py +++ b/data_safe_haven/infrastructure/programs/imperative_shm.py @@ -1,4 +1,4 @@ -from data_safe_haven.config import Context, SHMConfig +from data_safe_haven.config import Context, DSHPulumiConfig, SHMConfig from data_safe_haven.exceptions import ( DataSafeHavenAzureError, DataSafeHavenMicrosoftGraphError, @@ -178,6 +178,13 @@ def teardown(self) -> None: DataSafeHavenAzureError if any resources cannot be destroyed """ logger = get_logger() + if DSHPulumiConfig.remote_exists(self.context): + pulumi_config = DSHPulumiConfig.from_remote(self.context) + deployed = pulumi_config.project_names + if deployed: + logger.info(f"Found deployed SREs: {deployed}.") + msg = "Deployed SREs must be torn down before the SHM can be torn down." + raise DataSafeHavenAzureError(msg) try: logger.info( f"Removing [green]{self.context.description}[/] resource group {self.context.resource_group_name}." diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index dc6811ff9f..d675398bfc 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -95,6 +95,15 @@ def mock_pulumi_config_from_remote(mocker, pulumi_config): mocker.patch.object(DSHPulumiConfig, "from_remote", return_value=pulumi_config) +@fixture +def mock_pulumi_config_from_remote_fails(mocker): + mocker.patch.object( + DSHPulumiConfig, + "from_remote", + return_value=DataSafeHavenAzureError("mock from_remote failure"), + ) + + @fixture def mock_pulumi_config_from_remote_or_create(mocker, pulumi_config_empty): mocker.patch.object( @@ -114,6 +123,11 @@ def mock_pulumi_config_upload(mocker): mocker.patch.object(DSHPulumiConfig, "upload", return_value=None) +@fixture +def mock_pulumi_config_remote_exists(mocker): + mocker.patch.object(DSHPulumiConfig, "remote_exists", return_value=True) + + @fixture def mock_shm_config_from_remote(mocker, shm_config): mocker.patch.object(SHMConfig, "from_remote", return_value=shm_config) diff --git a/tests/commands/test_shm.py b/tests/commands/test_shm.py index 6480a9531f..e8f3919ed9 100644 --- a/tests/commands/test_shm.py +++ b/tests/commands/test_shm.py @@ -43,8 +43,9 @@ def test_teardown( runner, mock_imperative_shm_teardown_then_exit, # noqa: ARG002 mock_shm_config_from_remote, # noqa: ARG002 + mock_shm_config_remote_exists, # noqa: ARG002 ): - result = runner.invoke(shm_command_group, ["teardown"]) + result = runner.invoke(shm_command_group, ["teardown"], input="y") assert result.exit_code == 1 assert "mock teardown" in result.stdout @@ -62,9 +63,48 @@ def test_auth_failure( self, runner, mock_azuresdk_get_credential_failure, # noqa: ARG002 + mock_shm_config_remote_exists, # noqa: ARG002 ): result = runner.invoke(shm_command_group, ["teardown"]) assert result.exit_code == 1 assert "mock get_credential\n" in result.stdout assert "mock get_credential error" in result.stdout assert "Could not teardown Safe Haven Management environment." in result.stdout + + def test_teardown_sres_exist( + self, + runner, + mock_azuresdk_get_subscription_name, # noqa: ARG002 + mock_pulumi_config_from_remote, # noqa: ARG002 + mock_pulumi_config_remote_exists, # noqa: ARG002 + mock_shm_config_from_remote, # noqa: ARG002 + mock_shm_config_remote_exists, # noqa: ARG002 + ): + result = runner.invoke(shm_command_group, ["teardown"], input="y") + assert result.exit_code == 1 + assert "Found deployed SREs" in result.stdout + + def test_teardown_user_cancelled( + self, + runner, + mock_azuresdk_get_subscription_name, # noqa: ARG002 + mock_pulumi_config_from_remote, # noqa: ARG002 + mock_shm_config_from_remote, # noqa: ARG002 + mock_shm_config_remote_exists, # noqa: ARG002 + ): + result = runner.invoke(shm_command_group, ["teardown"], input="n") + assert result.exit_code == 0 + assert "cancelled" in result.stdout + + def test_teardown_no_pulumi_config( + self, + runner, + mock_azuresdk_get_subscription_name, # noqa: ARG002 + mock_pulumi_config_from_remote_fails, # noqa: ARG002 + mock_shm_config_from_remote, # noqa: ARG002 + mock_imperative_shm_teardown_then_exit, # noqa: ARG002 + mock_shm_config_remote_exists, # noqa: ARG002 + ): + result = runner.invoke(shm_command_group, ["teardown"], input="y") + assert result.exit_code == 1 + assert "mock teardown" in result.stdout diff --git a/tests/commands/test_sre.py b/tests/commands/test_sre.py index 1e5f0aabfc..a13518a878 100644 --- a/tests/commands/test_sre.py +++ b/tests/commands/test_sre.py @@ -103,11 +103,10 @@ def test_teardown( runner: CliRunner, mock_ip_1_2_3_4, # noqa: ARG002 mock_pulumi_config_from_remote, # noqa: ARG002 - mock_shm_config_from_remote, # noqa: ARG002 mock_sre_config_from_remote, # noqa: ARG002 mock_sre_project_manager_teardown_then_exit, # noqa: ARG002 ) -> None: - result = runner.invoke(sre_command_group, ["teardown", "sandbox"]) + result = runner.invoke(sre_command_group, ["teardown", "sandbox"], input="y") assert result.exit_code == 1 assert "mock teardown" in result.stdout @@ -138,3 +137,15 @@ def test_auth_failure( assert result.exit_code == 1 assert "mock get_credential\n" in result.stdout assert "mock get_credential error" in result.stdout + + def test_teardown_cancelled( + self, + runner: CliRunner, + mock_ip_1_2_3_4, # noqa: ARG002 + mock_pulumi_config_from_remote, # noqa: ARG002 + mock_sre_config_from_remote, # noqa: ARG002 + mock_sre_project_manager_teardown_then_exit, # noqa: ARG002 + ) -> None: + result = runner.invoke(sre_command_group, ["teardown", "sandbox"], input="n") + assert result.exit_code == 0 + assert "cancelled by user" in result.stdout