Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 186 additions & 41 deletions Babylon/commands/macro/destroy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from logging import getLogger

from click import command, echo, option, style
from click import command, confirm, echo, option, style

from Babylon.commands.api.organization import get_organization_api_instance
from Babylon.commands.api.solution import get_solution_api_instance
Expand All @@ -22,46 +22,117 @@
env = Environment()


@command()
@injectcontext()
@retrieve_state
@option("--include", "include", multiple=True, type=str, help="Specify the resources to destroy.")
@option("--exclude", "exclude", multiple=True, type=str, help="Specify the resources to exclude from destruction.")
def destroy(state: dict, include: tuple[str], exclude: tuple[str]):
"""Macro Destroy"""
organization, solution, workspace, webapp = resolve_inclusion_exclusion(include, exclude)
echo(style(f"\n🔥 Starting Destruction Process in namespace: {env.environ_id}", bold=True, fg="red"))
keycloak_token, config = get_keycloak_token()
def _build_targeted_resources(state: dict, organization: bool, solution: bool, workspace: bool, webapp: bool) -> list[tuple[str, str]]:
"""Map active resource flags to their (label, current-ID) pairs from the state."""
api_state = state["services"]["api"]
webapp_state = state["services"].get("webapp", {})
resource_map = [
(organization, "Organization", api_state.get("organization_id") or "(NOT DEPLOYED)"),
(solution, "Solution", api_state.get("solution_id") or "(NOT DEPLOYED)"),
(workspace, "Workspace", api_state.get("workspace_id") or "(NOT DEPLOYED)"),
(webapp, "Web App", webapp_state.get("webapp_name") or "(NOT DEPLOYED)"),
]
return [(label, value) for flag, label, value in resource_map if flag]


def _build_scope_message(include: tuple[str], exclude: tuple[str], targeted: list[tuple[str, str]]) -> str:
"""Return a human-readable sentence describing the destroy scope."""
if include:
names = " and ".join(label.lower() for label, _ in targeted)
return f"Only the selected {names} will be destroyed."
if exclude:
excluded_names = " and ".join(exclude)
return f"All resources will be destroyed except the selected {excluded_names}."
return "All resources in this environment will be destroyed."


def _confirm_destroy(
include: tuple[str],
exclude: tuple[str],
targeted: list[tuple[str, str]],
yes: bool = False,
) -> bool:
"""Display the destruction warning banner and prompt the user for confirmation.

When *yes* is True the prompt is skipped and True is returned immediately,
which is useful for automated environments and unit tests.

Returns True if the destruction should proceed, False if the user cancelled.
"""
scope_msg = _build_scope_message(include, exclude, targeted)

echo()
echo(style(" ╭─────────────────────────────────────────────────────────────╮", fg="red"))
echo(style(" │ ⚠ DESTRUCTIVE ACTION │", fg="red", bold=True))
echo(style(" ╰─────────────────────────────────────────────────────────────╯", fg="red"))
echo()
echo(f" State {style(f'state-{env.context_id}-{env.environ_id}', fg='cyan', bold=True)}")
echo()
echo(style(" Resources to be destroyed:", fg="white", bold=True))
for label, value in targeted:
echo(f" {style('•', fg='red')} {style(label + ':', fg='cyan'):<22} {style(value, fg='white')}")
echo()
echo(style(f" {scope_msg}", fg="yellow"))
echo(style(" This action cannot be undone.", fg="red", bold=True))
echo()

if yes:
echo(style(" --yes flag detected ! skipping interactive confirmation.", fg="yellow"))
return True

return confirm(
style(" Continue with destruction?", fg="white", bold=True),
default=False,
)


def _destroy_workspace_resources(state: dict, config: dict, keycloak_token: str, org_id: str) -> None:
"""Delete all workspace-level resources: Postgres schema, Kubernetes resources,
Superset assets, and the Workspace API object."""
api_state = state["services"]["api"]
schema_state = state["services"]["postgres"]

destroy_postgres_schema(schema_state["schema_name"], state)
delete_kubernetes_resources(
namespace=env.environ_id,
organization_id=org_id,
workspace_id=api_state["workspace_id"],
)

superset_url = (config.get("superset_url") or "").rstrip("/")
if superset_url:
logger.info(" [dim]→ Deleting Superset assets ...[/dim]")
delete_superset_assets(
base_url=superset_url,
superset_config=config,
workspace_id=api_state["workspace_id"],
)
else:
logger.warning(" [yellow]⚠[/yellow] superset_url not configured skipping Superset cleanup")

api = get_workspace_api_instance(config=config, keycloak_token=keycloak_token)
delete_api_resource(api.delete_workspace, "Workspace", org_id, api_state["workspace_id"], state, "workspace_id")


def _execute_destroy(
state: dict,
config: dict,
keycloak_token: str,
organization: bool,
solution: bool,
workspace: bool,
webapp: bool,
) -> None:
"""Call the appropriate delete helpers for each resource flagged for destruction."""
api_state = state["services"]["api"]
org_id = api_state["organization_id"]

if solution:
api = get_solution_api_instance(config=config, keycloak_token=keycloak_token)
delete_api_resource(api.delete_solution, "Solution", org_id, api_state["solution_id"], state, "solution_id")

if workspace:
destroy_postgres_schema(schema_state["schema_name"], state)
delete_kubernetes_resources(
namespace=env.environ_id,
organization_id=org_id,
workspace_id=api_state["workspace_id"],
)
# --- Superset cleanup
superset_url = (config.get("superset_url") or "").rstrip("/")
if superset_url:
logger.info(" [dim]→ Deleting Superset assets ...[/dim]")
delete_superset_assets(
base_url=superset_url,
superset_config=config,
workspace_id=api_state["workspace_id"],
)
else:
logger.warning(" [yellow]⚠[/yellow] superset_url not configured skipping Superset cleanup")

api = get_workspace_api_instance(config=config, keycloak_token=keycloak_token)
delete_api_resource(api.delete_workspace, "Workspace", org_id, api_state["workspace_id"], state, "workspace_id")
_destroy_workspace_resources(state, config, keycloak_token, org_id)

if organization:
api = get_organization_api_instance(config=config, keycloak_token=keycloak_token)
Expand All @@ -70,29 +141,103 @@ def destroy(state: dict, include: tuple[str], exclude: tuple[str]):
if webapp:
destroy_webapp(state)

# --- State Persistence ---
env.store_state_in_local(state=state)
if state.get("remote"):
logger.info(" [dim]☁ Syncing state cleanup to kubernetes...[/dim]")

def _is_full_destroy(state: dict) -> bool:
"""Return True when every tracked resource ID has been cleared from the state.

A single populated ID means the destroy was partial and states must be kept.
"""
svc = state.get("services", {})
api_ids = svc.get("api", {})
return (
not api_ids.get("organization_id")
and not api_ids.get("solution_id")
and not api_ids.get("workspace_id")
and not svc.get("webapp", {}).get("webapp_name", "")
and not svc.get("postgres", {}).get("schema_name", "")
)


def _cleanup_local_state(state: dict, full_destroy: bool) -> None:
"""Delete the local state file on a full destroy, or persist the updated state on a partial one."""
if full_destroy:
logger.info(" [dim]🗑 All resources cleared ! removing local state file...[/dim]")
if not env.delete_state_in_local():
logger.warning(
" [yellow]⚠[/yellow] Could not delete the local state file destroy succeeded but the file may need manual cleanup."
)
else:
logger.info(" [dim]↻ Partial destroy ! persisting updated state locally...[/dim]")
env.store_state_in_local(state=state)


def _cleanup_remote_state(state: dict, full_destroy: bool) -> None:
"""Delete the Kubernetes secret on a full destroy, or sync the updated state on a partial one.

No-op when the state has no remote backend configured.
"""
if not state.get("remote"):
return

if full_destroy:
logger.info(" [dim]☁ All resources cleared ! removing remote state secret from Kubernetes...[/dim]")
if not env.delete_state_in_kubernetes():
logger.warning(
" [yellow]⚠[/yellow] Could not delete the remote state secret !"
"destroy succeeded but the secret may need manual cleanup."
)
else:
logger.info(" [dim]↻ Partial destroy ! syncing updated state to Kubernetes...[/dim]")
env.store_state_in_kubernetes(state=state)

# --- Final Destruction Summary ---

def _print_destruction_summary(state: dict) -> None:
"""Print the post-destroy summary table showing the final status of every resource."""
echo(style("\n📋 Destruction Summary", bold=True, fg="white"))
final_state = env.get_state_from_local()
services = final_state.get("services")
api_data = services.get("api")

services = state.get("services", {})
api_data = services.get("api", {})
for key, value in api_data.items():
label_text = f" • {key.replace('_', ' ').title()}"
status = "DELETED" if not value else value
color = "red" if status == "DELETED" else "green"
echo(f"{style(f'{label_text:<20}:', fg='cyan', bold=True)} {style(status, fg=color)}")

webapp_data = services.get("webapp", {})
webapp_id = webapp_data.get("webapp_name")
webapp_id = services.get("webapp", {}).get("webapp_name")
label_text = " • Webapp Name"
status = "DELETED" if not webapp_id else webapp_id
color = "red" if status == "DELETED" else "green"
echo(f"{style(f'{label_text:<20}:', fg='cyan', bold=True)} {style(status, fg=color)}")

echo(style("\n✨ Cleanup process complete", fg="white", bold=True))


@command()
@injectcontext()
@retrieve_state
@option("--include", "include", multiple=True, type=str, help="Specify the resources to destroy.")
@option("--exclude", "exclude", multiple=True, type=str, help="Specify the resources to exclude from destruction.")
@option("--yes", "-y", is_flag=True, default=False, help="Skip the interactive confirmation prompt.")
def destroy(state: dict, include: tuple[str], exclude: tuple[str], yes: bool):
"""Macro Destroy"""
organization, solution, workspace, webapp = resolve_inclusion_exclusion(include, exclude)

targeted = _build_targeted_resources(state, organization, solution, workspace, webapp)

if not _confirm_destroy(include, exclude, targeted, yes=yes):
echo()
echo(style(" ✓ Deletion cancelled ! no resources were deleted.", fg="green", bold=True))
echo()
return CommandResponse.success()

echo(style(f"\n🔥 Starting Destruction Process in namespace: {env.environ_id}", bold=True, fg="red"))
keycloak_token, config = get_keycloak_token()

_execute_destroy(state, config, keycloak_token, organization, solution, workspace, webapp)

full_destroy = _is_full_destroy(state)
_cleanup_local_state(state, full_destroy)
_cleanup_remote_state(state, full_destroy)

_print_destruction_summary(state)
return CommandResponse.success()
56 changes: 55 additions & 1 deletion Babylon/utils/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
from yaml import SafeLoader, YAMLError, dump, load, safe_load

from Babylon.utils import ORIGINAL_CONFIG_FOLDER_PATH, ORIGINAL_TEMPLATE_FOLDER_PATH
from Babylon.utils.kubernetes_state import STATE_LABEL_KEY, STATE_LABEL_VALUE, retrieve_state_from_kubernetes, save_state_in_kubernetes
from Babylon.utils.kubernetes_state import (
STATE_LABEL_KEY,
STATE_LABEL_VALUE,
delete_state_from_kubernetes,
retrieve_state_from_kubernetes,
save_state_in_kubernetes,
)
from Babylon.utils.working_dir import WorkingDir
from Babylon.utils.yaml_utils import yaml_to_json

Expand Down Expand Up @@ -188,12 +194,41 @@ def store_state_in_local(self, state: dict):
s = self.state_dir / state_file
s.write_bytes(data=dump(state).encode("utf-8"))

def delete_state_in_local(self) -> bool:
"""Delete the local state file for the current context/tenant.

Returns ``True`` when the file was deleted or did not exist.
Returns ``False`` on unexpected OS errors (callers should warn, not fail).
"""
state_file = self.state_dir / f"state.{self.context_id}.{self.environ_id}.yaml"
try:
if state_file.exists():
state_file.unlink()
logger.info(f" [green]✔[/green] Local state file [cyan]{state_file.name}[/cyan] deleted")
else:
logger.info(f" [dim]→ Local state file [cyan]{state_file.name}[/cyan] already removed nothing to delete[/dim]")
return True
except OSError as exc:
logger.error(f" [bold red]✘[/bold red] Could not delete local state file [cyan]{state_file.name}[/cyan]: {exc}")
return False

def store_state_in_kubernetes(self, state: dict, namespace: str = "", secret_name: str = "") -> None:
"""Persist *state* as a Kubernetes Secret."""
ns = namespace or self.environ_id
name = secret_name or f"babylon-state-{self.context_id}-{self.environ_id}"
save_state_in_kubernetes(self.get_kubernetes_client(), namespace=ns, secret_name=name, state_data=state)

def delete_state_in_kubernetes(self, namespace: str = "", secret_name: str = "") -> bool:
"""Delete the Babylon state Secret from Kubernetes.

Returns ``True`` when the secret was deleted or was already absent.
Returns ``False`` on unexpected API errors (destroy already succeeded,
so callers should log a warning rather than fail).
"""
ns = namespace or self.environ_id
name = secret_name or f"babylon-state-{self.context_id}-{self.environ_id}"
return delete_state_from_kubernetes(self.get_kubernetes_client(), namespace=ns, secret_name=name)

def get_state_from_kubernetes(self, namespace: str = "", secret_name: str = "") -> dict:
"""Retrieve state from a Kubernetes Secret.

Expand All @@ -205,6 +240,19 @@ def get_state_from_kubernetes(self, namespace: str = "", secret_name: str = "")
name = secret_name or f"babylon-state-{self.context_id}-{self.environ_id}"
result = retrieve_state_from_kubernetes(self.get_kubernetes_client(), namespace=ns, secret_name=name)
if result is None:
# Remote secret does not exist yet.
# Bootstrap from local state to avoid losing already-deployed resources.
local_state = self.get_state_from_local()
has_local_resources = any(
local_state.get("services", {}).get(svc, {}).get(key)
for svc, key in [("api", "organization_id"), ("api", "workspace_id"), ("api", "solution_id")]
)
if has_local_resources:
logger.info(f" [dim]→ Remote state not found. Bootstrapping from local state ([cyan]{name}[/cyan])...[/dim]")
Comment thread
MohcineTor marked this conversation as resolved.
Dismissed
local_state["remote"] = True
save_state_in_kubernetes(self.get_kubernetes_client(), namespace=ns, secret_name=name, state_data=local_state)
return local_state
# Truly empty project return a blank default state.
return {
"context": self.context_id,
"tenant": self.environ_id,
Expand Down Expand Up @@ -314,6 +362,12 @@ def retrieve_config(self):
return self.get_config_from_k8s_secret_by_tenant("babylon-config", self.environ_id)

def retrieve_state_func(self):
# Resolve `remote` from the persisted local state when the constructor
# default (False) has not been overridden by get_ns_from_text().
if not self.remote:
local_state = self.get_state_from_local()
self.remote = local_state.get("remote", False)

if self.remote:
state = self.get_state_from_kubernetes()
else:
Expand Down
20 changes: 20 additions & 0 deletions Babylon/utils/kubernetes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ def save_state_in_kubernetes(k8s_client: client.CoreV1Api, namespace: str, secre
sys.exit(1)


def delete_state_from_kubernetes(k8s_client: client.CoreV1Api, namespace: str, secret_name: str) -> bool:
"""Delete the Babylon state Secret from *namespace*."""
try:
k8s_client.delete_namespaced_secret(name=secret_name, namespace=namespace)
logger.info(f" [green]✔[/green] State secret [cyan]{secret_name}[/cyan] deleted from namespace [cyan]{namespace}[/cyan]")
Comment thread
MohcineTor marked this conversation as resolved.
Dismissed
return True
except ApiException as exc:
if exc.status == 404:
logger.info(
f" [dim]→ State secret [cyan]{secret_name}[/cyan] already deleted from namespace "
f"[cyan]{namespace}[/cyan] nothing to delete[/dim]"
Comment thread
MohcineTor marked this conversation as resolved.
Dismissed
)
return True
logger.error(f" [bold red]✘[/bold red] Kubernetes API error while deleting state secret (HTTP {exc.status}): {exc.reason}")
return False
except Exception as exc:
logger.error(f" [bold red]✘[/bold red] Failed to connect to the Kubernetes cluster: {exc}")
return False


def retrieve_state_from_kubernetes(k8s_client: client.CoreV1Api, namespace: str, secret_name: str) -> dict | None:
"""Read state from a Kubernetes Secret and return it as a dictionary.

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/test_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ babylon api about

babylon init azure
babylon apply --exclude webapp project
babylon destroy
babylon destroy --yes
Loading