From a66b6484abfd9e23eecd6a0bc960c76344ecd743 Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Wed, 4 Feb 2026 15:50:34 +0100 Subject: [PATCH 1/3] feat: add deploy webapp macro command --- Babylon/commands/macro/apply.py | 45 ++++++---- Babylon/commands/macro/deploy.py | 20 ++++- Babylon/commands/macro/deploy_webapp.py | 85 +++++++++++++++++++ Babylon/commands/macro/destroy.py | 77 +++++++++++++++-- Babylon/commands/macro/init.py | 19 ++++- .../working_dir/.templates/yaml/Webapp.yaml | 14 +++ .../.templates/yaml/variables.yaml | 10 +++ Babylon/utils/environment.py | 12 ++- 8 files changed, 254 insertions(+), 28 deletions(-) create mode 100644 Babylon/commands/macro/deploy_webapp.py create mode 100644 Babylon/templates/working_dir/.templates/yaml/Webapp.yaml diff --git a/Babylon/commands/macro/apply.py b/Babylon/commands/macro/apply.py index 75fa90c9..59393eb2 100644 --- a/Babylon/commands/macro/apply.py +++ b/Babylon/commands/macro/apply.py @@ -8,6 +8,7 @@ from Babylon.commands.macro.deploy import resolve_inclusion_exclusion from Babylon.commands.macro.deploy_organization import deploy_organization from Babylon.commands.macro.deploy_solution import deploy_solution +from Babylon.commands.macro.deploy_webapp import deploy_webapp from Babylon.commands.macro.deploy_workspace import deploy_workspace from Babylon.utils.decorators import injectcontext from Babylon.utils.environment import Environment @@ -31,7 +32,8 @@ def load_resources_from_files(files_to_deploy: list[PathlibPath]) -> tuple[list, organizations = list(filter(lambda x: x.get("kind") == "Organization", resources)) solutions = list(filter(lambda x: x.get("kind") == "Solution", resources)) workspaces = list(filter(lambda x: x.get("kind") == "Workspace", resources)) - return (organizations, solutions, workspaces) + webapps = list(filter(lambda x: x.get("kind") == "Webapp", resources)) + return (organizations, solutions, workspaces, webapps) def deploy_objects(objects: list, object_type: str): @@ -44,6 +46,23 @@ def deploy_objects(objects: list, object_type: str): deploy_solution(namespace=namespace, file_content=content) elif object_type == "workspace": deploy_workspace(namespace=namespace, file_content=content) + elif object_type == "webapp": + deploy_webapp(namespace=namespace, file_content=content) + + +def print_section(data: dict, highlight_urls: bool = False): + for key, value in data.items(): + if not value: + continue + label = f" • {key.replace('_', ' ').title()}" + styled_label = style(f"{label:<20}:", fg="cyan", bold=True) + + if highlight_urls and "url" in key.lower(): + styled_value = style(str(value).strip(), fg="bright_blue", underline=True) + else: + styled_value = style(str(value).strip(), fg="white") + + echo(f"{styled_label} {styled_value}") @command() @@ -66,30 +85,24 @@ def apply( variables_files: tuple[PathlibPath], ): """Macro Apply""" - organization, solution, workspace = resolve_inclusion_exclusion(include, exclude) + organization, solution, workspace, webapp = resolve_inclusion_exclusion(include, exclude) files = list(PathlibPath(deploy_dir).iterdir()) files_to_deploy = list(filter(lambda x: x.suffix in [".yaml", ".yml"], files)) env.set_variable_files(variables_files) - organizations, solutions, workspaces = load_resources_from_files(files_to_deploy) + organizations, solutions, workspaces, webapps = load_resources_from_files(files_to_deploy) if organization: deploy_objects(organizations, "organization") if solution: deploy_objects(solutions, "solution") if workspace: deploy_objects(workspaces, "workspace") - + if webapp: + deploy_objects(webapps, "webapp") final_state = env.get_state_from_local() - services = final_state.get("services") - api_data = services.get("api") + services = final_state.get("services", {}) + api_data = services.get("api", {}) + webapp_data = services.get("webapp", {}) echo(style("\nšŸ“‹ Deployment Summary", bold=True, fg="yellow")) - for key, value in api_data.items(): - if not value: - continue - label = f" • {key.replace('_', ' ').title()}" - - # We pad the label to 20 chars to keep the colons aligned - styled_label = style(f"{label:<20}:", fg="cyan", bold=True) - clean_value = str(value).strip() - styled_value = style(clean_value, fg="white") - echo(f"{styled_label} {styled_value}") + print_section(api_data) + print_section(webapp_data) echo(style("\n✨ Deployment process complete", fg="white", bold=True)) diff --git a/Babylon/commands/macro/deploy.py b/Babylon/commands/macro/deploy.py index f42d3ef9..17c2eecf 100644 --- a/Babylon/commands/macro/deploy.py +++ b/Babylon/commands/macro/deploy.py @@ -21,7 +21,7 @@ def validate_inclusion_exclusion( logger.error(" Cannot use [bold]--include[/bold] and [bold]--exclude[/bold] at the same time") raise Abort() - allowed_values = ("organization", "solution", "workspace") + allowed_values = ("organization", "solution", "workspace", "webapp") invalid_items = [i for i in include + exclude if i not in allowed_values] if invalid_items: echo(style("\n ✘ Invalid Arguments Detected", fg="red", bold=True)) @@ -53,15 +53,18 @@ def resolve_inclusion_exclusion( organization = True solution = True workspace = True + webapp = True if include: # if only is specified include by condition organization = "organization" in include solution = "solution" in include workspace = "workspace" in include + webapp = "webapp" in include if exclude: # if exclude is specified exclude by condition organization = "organization" not in exclude solution = "solution" not in exclude workspace = "workspace" not in exclude - return (organization, solution, workspace) + webapp = "webapp" not in exclude + return (organization, solution, workspace, webapp) def diff( @@ -150,3 +153,16 @@ def update_object_security( logger.error( f" [bold red]✘[/bold red] Failed to delete access control for id [magenta]{entry_id}[/magenta]: {e}" ) + + +def dict_to_tfvars(payload: dict) -> str: + """Transforme un dictionnaire en format texte key = "value" pour Terraform HCL""" + lines = [] + for key, value in payload.items(): + if isinstance(value, bool): + lines.append(f"{key} = {str(value).lower()}") + elif isinstance(value, (int, float)): + lines.append(f"{key} = {value}") + else: + lines.append(f'{key} = "{value}"') + return "\n".join(lines) diff --git a/Babylon/commands/macro/deploy_webapp.py b/Babylon/commands/macro/deploy_webapp.py new file mode 100644 index 00000000..6e8fdea9 --- /dev/null +++ b/Babylon/commands/macro/deploy_webapp.py @@ -0,0 +1,85 @@ +import os +import subprocess +import sys +from logging import getLogger +from pathlib import Path + +from click import echo, style + +from Babylon.commands.macro.deploy import dict_to_tfvars +from Babylon.utils.environment import Environment + +logger = getLogger(__name__) +env = Environment() + + +def deploy_webapp(namespace: str, file_content: str): + echo(style(f"\nšŸš€ Deploying webapp in namespace: {env.environ_id}", bold=True, fg="cyan")) + + env.get_ns_from_text(content=namespace) + state = env.retrieve_state_func() + content = env.fill_template(data=file_content, state=state) + payload: dict = content.get("spec").get("payload", {}) + current_os = sys.platform + tf_dir = Path(str(env.working_dir)).parent / "terraform-webapp" + tfvars_path = tf_dir / "terraform.tfvars" + + if "win" in current_os: + script_name = "_run-terraform.ps1" + executable = ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", f"./{script_name}"] + else: + script_name = "_run-terraform.sh" + executable = ["/bin/bash", f"./{script_name}"] + if (tf_dir / script_name).exists(): + os.chmod(tf_dir / script_name, 0o755) + + script_path = tf_dir / script_name + + if not script_path.exists(): + logger.error(f" [bold red]✘[/bold red]Script not found at {script_path}") + return + try: + hcl_content = dict_to_tfvars(payload) + with open(tfvars_path, "w") as f: + f.write(hcl_content) + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to write tfvars: {e}") + return + logger.info(" [dim]→ Running Terraform deployment...[/dim]") + try: + process = subprocess.Popen( + executable, cwd=tf_dir, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 + ) + for line in process.stdout: + clean_line = line.strip() + if not clean_line: + continue + if any(key in clean_line for key in ["Initializing", "Upgrading", "Finding", "Refreshing"]): + echo(style(f" {clean_line}", fg="white")) + elif any(key in clean_line for key in ["Success", "complete", "Resources:"]): + echo(style(f" {clean_line}", fg="green")) + elif "Error" in clean_line or "error" in clean_line: + echo(style(f" {clean_line}", fg="red", bold=True)) + else: + echo(style(f" {clean_line}", fg="white")) + return_code = process.wait() + webapp_name = payload.get("webapp_name") + cluster_domain = payload.get("cluster_domain") + tenant_name = payload.get("tenant") + webapp_url = f"https://{cluster_domain}/{tenant_name}/webapp-{webapp_name}" + services = state.get("services", {}) + if "webapp" not in services: + services["webapp"] = {} + services["webapp"]["webapp_name"] = f"webapp-{webapp_name}" + services["webapp"]["webapp_url"] = webapp_url + if return_code == 0: + logger.info( + f" [bold green]āœ”[/bold green] WebApp [bold white]{webapp_name}[/bold white] deployed successfully" + ) + env.store_state_in_local(state) + if env.remote: + env.store_state_in_cloud(state) + else: + logger.error(f" [bold red]✘[/bold red] Deployment failed with exit code {return_code}") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Execution error: {e}") diff --git a/Babylon/commands/macro/destroy.py b/Babylon/commands/macro/destroy.py index 5a84bc19..4f725c03 100644 --- a/Babylon/commands/macro/destroy.py +++ b/Babylon/commands/macro/destroy.py @@ -1,4 +1,6 @@ +import subprocess from logging import getLogger +from pathlib import Path from typing import Callable from click import command, echo, option, style @@ -16,6 +18,53 @@ env = Environment() +def _destroy_webapp(state: dict): + """Interne: Lance le process Terraform Destroy""" + logger.info(" [dim]→ Running Terraform destroy for WebApp resources...[/dim]") + webapp_state = state.get("services", {}).get("webapp", {}) + webapp_neme = webapp_state.get("webapp_name") + if not webapp_neme: + logger.warning(" [yellow]⚠[/yellow] [dim]No WebApp found in state! skipping deletion [dim]") + return + tf_dir = Path(str(env.working_dir)).parent / "terraform-webapp" + + if not tf_dir.exists(): + logger.error(f" [bold red]✘[/bold red] Terraform directory not found at {tf_dir}") + return + try: + process = subprocess.Popen( + ["terraform", "destroy", "-auto-approve"], + cwd=tf_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + for line in process.stdout: + clean_line = line.strip() + if not clean_line: + continue + + if "Destroy complete!" in clean_line or "Resources:" in clean_line: + echo(style(f" {clean_line}", fg="green")) + elif "Error" in clean_line: + echo(style(f" {clean_line}", fg="red", bold=True)) + else: + echo(style(f" {clean_line}", fg="white")) + + process.wait() + if process.returncode == 0: + # Nettoyage du state webapp + state["services"]["webapp"]["webapp_name"] = "" + state["services"]["webapp"]["webapp_url"] = "" + logger.info(f" [green]āœ”[/green] WebApp [magenta]{webapp_neme}[/magenta] destroyed") + else: + logger.error(f" [bold red]✘[/bold red] Terraform destroy failed (Code {process.returncode})") + + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Error during WebApp destruction: {e}") + + def _delete_resource( api_call: Callable[..., None], resource_name: str, org_id: str | None, resource_id: str, state: dict, state_key: str ): @@ -34,7 +83,14 @@ def _delete_resource( logger.info(f" [bold green]āœ”[/bold green] {resource_name} [magenta]{resource_id}[/magenta] deleted") state["services"]["api"][state_key] = "" except Exception as e: - logger.error(f" [bold red]✘[/bold red]] Error deleting {resource_name.lower()} {resource_id} reason: {e}") + error_msg = str(e) + if "404" in error_msg or "Not Found" in error_msg: + logger.info( + f" [bold yellow]⚠[/bold yellow] {resource_name} [magenta]{resource_id}[/magenta] already deleted (404)" + ) + state["services"]["api"][state_key] = "" + else: + logger.error(f" [bold red]✘[/bold red] Error deleting {resource_name.lower()} {resource_id} reason: {e}") @command() @@ -48,7 +104,7 @@ def destroy( exclude: tuple[str], ): """Macro Destroy""" - organization, solution, workspace = resolve_inclusion_exclusion(include, exclude) + organization, solution, workspace, webapp = resolve_inclusion_exclusion(include, exclude) # Header for the destructive operation echo(style(f"\nšŸ”„ Starting Destruction Process in namespace: {env.environ_id}", bold=True, fg="red")) keycloak_token, config = get_keycloak_token() @@ -57,7 +113,6 @@ def destroy( api_state = state["services"]["api"] org_id = api_state["organization_id"] - # 1. Targeted Deletions using the helper if solution: api = get_solution_api_instance(config=config, keycloak_token=keycloak_token) _delete_resource(api.delete_solution, "Solution", org_id, api_state["solution_id"], state, "solution_id") @@ -69,6 +124,9 @@ def destroy( api = get_organization_api_instance(config=config, keycloak_token=keycloak_token) _delete_resource(api.delete_organization, "Organization", None, org_id, state, "organization_id") + if webapp: + _destroy_webapp(state) + # --- State Persistence --- env.store_state_in_local(state=state) if state.get("remote"): @@ -87,10 +145,15 @@ def destroy( # We check if the ID is now empty (which means it was deleted) status = "DELETED" if not value else value color = "red" if status == "DELETED" else "green" - - styled_label = style(f"{label_text:<20}:", fg="cyan", bold=True) - styled_status = style(status, fg=color) - echo(f"{styled_label} {styled_status}") + echo(f"{style(f'{label_text:<20}:', fg='cyan', bold=True)} {style(status, fg=color)}") + + # Affichage WebApp + webapp_data = services.get("webapp", {}) + webapp_id = webapp_data.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)) return CommandResponse.success() diff --git a/Babylon/commands/macro/init.py b/Babylon/commands/macro/init.py index af672f7a..11d94580 100644 --- a/Babylon/commands/macro/init.py +++ b/Babylon/commands/macro/init.py @@ -1,3 +1,4 @@ +import subprocess from logging import getLogger from os import getcwd from pathlib import Path @@ -26,7 +27,23 @@ def init(project_folder: str, variables_file: str): if variables_path.exists(): logger.warning(f"Configuration file [bold]{variables_file}[/bold] already exists.") return None - project_yaml_files = ["Organization.yaml", "Solution.yaml", "Workspace.yaml", "Dataset.yaml", "Runner.yaml"] + tf_webapp_path = Path(getcwd()) / "terraform-webapp" + repo_url = "https://github.com/Cosmo-Tech/terraform-webapp.git" + if not tf_webapp_path.exists(): + logger.info(" [dim]→ Cloning Terraform WebApp module...[/dim]") + try: + subprocess.run(["git", "clone", "-q", repo_url, str(tf_webapp_path)], check=True, stdout=subprocess.DEVNULL) + logger.info(" [green]āœ”[/green] Terraform WebApp module cloned") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to clone Terraform repo: {e}") + project_yaml_files = [ + "Organization.yaml", + "Solution.yaml", + "Workspace.yaml", + "Dataset.yaml", + "Runner.yaml", + "Webapp.yaml", + ] try: # Create project directory project_path.mkdir(parents=True, exist_ok=True) diff --git a/Babylon/templates/working_dir/.templates/yaml/Webapp.yaml b/Babylon/templates/working_dir/.templates/yaml/Webapp.yaml new file mode 100644 index 00000000..bd467760 --- /dev/null +++ b/Babylon/templates/working_dir/.templates/yaml/Webapp.yaml @@ -0,0 +1,14 @@ +kind: Webapp +namespace: + remote: false +spec: + payload: + cloud_provider: "azure" + cluster_name: "{{cluster_name}}" + cluster_domain: "{{cluster_domain}}" + tenant: "{{tenant}}" + webapp_name: "{{webapp_name}}" + organization_id: "{{services['api.organization_id']}}" + azure_subscription_id: "{{azure_subscription_id}}" + azure_entra_tenant_id: "{{azure_entra_tenant_id}}" + powerbi_app_deploy: false \ No newline at end of file diff --git a/Babylon/templates/working_dir/.templates/yaml/variables.yaml b/Babylon/templates/working_dir/.templates/yaml/variables.yaml index b6dcd449..8dde224c 100644 --- a/Babylon/templates/working_dir/.templates/yaml/variables.yaml +++ b/Babylon/templates/working_dir/.templates/yaml/variables.yaml @@ -22,6 +22,16 @@ run_template_id: standalone runner_name: Babylon v5 Runner runTemplate_name: Standard simulation owner_name: toto +# Webapp +cloud_provider: azure +cluster_name: warpvy52ww +cluster_domain: warp.api.cosmotech.com +tenant: sphinx +webapp_name: business +organization_id: o-xxxxxxxxxxx +azure_subscription_id: a24b131f-bd0b-42e8-872a-bded9b91ab74 +azure_entra_tenant_id: e413b834-8be8-4822-a370-be619545cb49 +powerbi_app_deploy: false # ACL security: default: none diff --git a/Babylon/utils/environment.py b/Babylon/utils/environment.py index 43a6af6b..4fc0377d 100644 --- a/Babylon/utils/environment.py +++ b/Babylon/utils/environment.py @@ -200,7 +200,11 @@ def get_state_from_local(self): "organization_id": "", "solution_id": "", "workspace_id": "", - } + }, + "webapp": { + "webapp_name": "", + "webapp_url": "", + }, }, } state_data = load(state_file.open("r"), Loader=SafeLoader) @@ -221,7 +225,11 @@ def get_state_from_cloud(self) -> dict: "organization_id": "", "solution_id": "", "workspace_id": "", - } + }, + "webapp": { + "webapp_name": "", + "webapp_url": "", + }, }, } data = load(state_blob.download_blob().readall(), Loader=SafeLoader) From a532cee328732bfc435129801ade33885b48d17a Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Wed, 4 Feb 2026 17:14:36 +0100 Subject: [PATCH 2/3] change destroy webapp title function --- Babylon/commands/macro/destroy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Babylon/commands/macro/destroy.py b/Babylon/commands/macro/destroy.py index 4f725c03..9b541648 100644 --- a/Babylon/commands/macro/destroy.py +++ b/Babylon/commands/macro/destroy.py @@ -19,7 +19,7 @@ def _destroy_webapp(state: dict): - """Interne: Lance le process Terraform Destroy""" + """Run the Terraform destroy process for WebApp resources""" logger.info(" [dim]→ Running Terraform destroy for WebApp resources...[/dim]") webapp_state = state.get("services", {}).get("webapp", {}) webapp_neme = webapp_state.get("webapp_name") From 6ec31a634f4932301d547706b848261e8fbb79e5 Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Wed, 4 Feb 2026 17:53:28 +0100 Subject: [PATCH 3/3] fix: unit & e2e tests --- tests/e2e/test_e2e.sh | 2 +- tests/unit/test_macro.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/e2e/test_e2e.sh b/tests/e2e/test_e2e.sh index 2def95ac..bc427a4f 100755 --- a/tests/e2e/test_e2e.sh +++ b/tests/e2e/test_e2e.sh @@ -21,5 +21,5 @@ babylon namespace get-contexts babylon api about babylon init -babylon apply project +babylon apply --exclude webapp project babylon destroy \ No newline at end of file diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index ce78452b..8714b1ce 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -53,11 +53,12 @@ def test_solution_diff(): def test_resolve_inclusion_exclusion_no_filters(): - assert resolve_inclusion_exclusion(include=(), exclude=()) == (True, True, True) + assert resolve_inclusion_exclusion(include=(), exclude=()) == (True, True, True, True) def test_resolve_inclusion_exclusion_include_all_valid(): - assert resolve_inclusion_exclusion(include=("organization", "solution", "workspace"), exclude=()) == ( + assert resolve_inclusion_exclusion(include=("organization", "solution", "workspace", "webapp"), exclude=()) == ( + True, True, True, True, @@ -65,7 +66,8 @@ def test_resolve_inclusion_exclusion_include_all_valid(): def test_resolve_inclusion_exclusion_exclude_all_valid(): - assert resolve_inclusion_exclusion(include=(), exclude=("organization", "solution", "workspace")) == ( + assert resolve_inclusion_exclusion(include=(), exclude=("organization", "solution", "workspace", "webapp")) == ( + False, False, False, False, @@ -73,7 +75,12 @@ def test_resolve_inclusion_exclusion_exclude_all_valid(): def test_resolve_inclusion_exclusion_include_duplicates(): - assert resolve_inclusion_exclusion(include=("organization", "organization"), exclude=()) == (True, False, False) + assert resolve_inclusion_exclusion(include=("organization", "organization"), exclude=()) == ( + True, + False, + False, + False, + ) def test_resolve_inclusion_exclusion_invalid_exclude():