Skip to content
Open
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
45 changes: 29 additions & 16 deletions Babylon/commands/macro/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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()
Expand All @@ -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))
20 changes: 18 additions & 2 deletions Babylon/commands/macro/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ This function help to bridges the yaml-hcl format difference. It would be nice if the end user doesn't need to know the details of hcl or terraform as long as they know how to write or modify a yaml file this function would translate the necessary to hcl. In practice this is slightly more complicated, because this function probably works for the yaml files we have, however it is not robust or generic to convert any yaml to hcl correctly and this is not visible to the end user. So they might end up having difficult to find bugs because of an issue in the conversion.

I would suggest to break this in smaller steps. First expose the terraform to the end user to avoid hard bugs, when the codebase is more mature we can then hide it and make things modular.

"""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)
85 changes: 85 additions & 0 deletions Babylon/commands/macro/deploy_webapp.py
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

win could be cygwin and I don't think we want this. From the docs https://docs.python.org/3/library/sys.html#sys.platform the comparisson should probably be something like:

if current_os == 'win32':
  [...]
elif current_os == 'linux':
  [...]
else: 
  raise error

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}")
77 changes: 70 additions & 7 deletions Babylon/commands/macro/destroy.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +18,53 @@
env = Environment()


def _destroy_webapp(state: dict):
"""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")
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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not the simpler subprocess.run ? do we need an advanced behavior here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are way too many levels of if/else here, sonarqube will flag this as too complex

["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
):
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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")
Expand All @@ -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"):
Expand All @@ -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()
19 changes: 18 additions & 1 deletion Babylon/commands/macro/init.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import subprocess
from logging import getLogger
from os import getcwd
from pathlib import Path
Expand Down Expand Up @@ -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"
Copy link
Contributor

@sellisd sellisd Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are introducing some hidden dependencies here. Now Babylon depends on terraform-webapp which depends on terraform etc and this is not visible in the Babylon metadata (in pyprojects.toml). This will make tools that automatically scan dependencies (e.g. dependency track) to have a blind spot, and also users installing Babylon will not know they need to configure this dependency.
I suggest we first decide if this is an optional or required dependency: it would be optional if we have a use case where Babylon user would not need this dependency.
Then we should update the readme.md to include this and possibly also add a check to make sure the tools are available.
Finally we would need to somehow add the external dependency in pyproject.toml. There is a pending PEP to include external dependencies, but since it has not yet been accepted we need to find a way to do it that is compatible with the build toolchain (PEP 725 & 804).

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)
Expand Down
14 changes: 14 additions & 0 deletions Babylon/templates/working_dir/.templates/yaml/Webapp.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading