From 6d88f3d047b1d5a238565737cd6d6f2f66ff8a10 Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Tue, 31 Mar 2026 18:48:23 +0200 Subject: [PATCH 1/8] feat: add support for on-prem webapp deployment and improve init command to be flexible, verifying all necessary directories and creating them if missing --- Babylon/commands/macro/init.py | 213 +++++++++++++----- .../.templates/yaml/azure/Webapp.yaml | 14 ++ .../.templates/yaml/azure/variables.yaml | 43 ++++ .../.templates/yaml/kob/Webapp.yaml | 13 ++ .../.templates/yaml/kob/variables.yaml | 41 ++++ 5 files changed, 270 insertions(+), 54 deletions(-) create mode 100644 Babylon/templates/working_dir/.templates/yaml/azure/Webapp.yaml create mode 100644 Babylon/templates/working_dir/.templates/yaml/azure/variables.yaml create mode 100644 Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml create mode 100644 Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml diff --git a/Babylon/commands/macro/init.py b/Babylon/commands/macro/init.py index 62a29de4..ad62ddb5 100644 --- a/Babylon/commands/macro/init.py +++ b/Babylon/commands/macro/init.py @@ -4,72 +4,177 @@ from pathlib import Path from shutil import copy -from click import command, echo, option, style +from click import command, echo, option, style, argument, Choice from Babylon.utils.environment import Environment logger = getLogger(__name__) env = Environment() +# Constants + +_TF_WEBAPP_DIR = "terraform-webapp" +_TF_WEBAPP_REPO_URL = "https://github.com/Cosmo-Tech/terraform-webapp.git" +_VARIABLES_TEMPLATE = "variables.yaml" + +_PROJECT_YAML_FILES = [ + "Organization.yaml", + "Solution.yaml", + "Workspace.yaml", +] + +# Cloud providers that have their own yaml sub-directory +_SUPPORTED_CLOUD_PROVIDERS = {"azure", "kob"} + +# Private helpers + +def _get_provider_template(cloud_provider: str, filename: str) -> Path: + """Return the template path for *filename* scoped to *cloud_provider* when available, + falling back to the shared yaml directory otherwise.""" + provider = cloud_provider.lower() + if provider in _SUPPORTED_CLOUD_PROVIDERS: + return env.original_template_path / "yaml" / provider / filename + return env.original_template_path / "yaml" / filename + + +def _clone_webapp(tf_webapp_path: Path) -> None: + """Clone the Terraform WebApp repository into *tf_webapp_path*.""" + logger.info(" [dim]→ Cloning Terraform WebApp module...[/dim]") + try: + subprocess.run( + ["git", "clone", "-q", _TF_WEBAPP_REPO_URL, str(tf_webapp_path)], + check=True, + stdout=subprocess.DEVNULL, + ) + if tf_webapp_path.exists(): + logger.info(" [green]✔[/green] Terraform WebApp module cloned") + else: + logger.error(" [bold red]✘[/bold red] Terraform WebApp module was not created after cloning") + except subprocess.CalledProcessError as exc: + logger.error(f" [bold red]✘[/bold red] Failed to clone Terraform repo: {exc}") + + +def _ensure_webapp(tf_webapp_path: Path) -> None: + """Log success when *tf_webapp_path* exists, otherwise clone it.""" + if tf_webapp_path.exists(): + logger.info(" [green]✔[/green] Webapp directory [cyan]terraform-webapp[/cyan] already exists.") + else: + logger.warning(" [bold yellow]![/bold yellow] Webapp directory not found") + _clone_webapp(tf_webapp_path) + + +def _ensure_variables_file(variables_path: Path, variables_file: str, cloud_provider: str) -> None: + """Log success when *variables_path* exists, otherwise copy the template.""" + if variables_path.exists(): + logger.info(f" [green]✔[/green] Variables file [cyan]{variables_file}[/cyan] already exists.") + return + + logger.warning(" [bold yellow]![/bold yellow] Variables file not found") + logger.info(" [dim]→ Generating variables file from template...[/dim]") + try: + variables_template = _get_provider_template(cloud_provider, _VARIABLES_TEMPLATE) + copy(variables_template, variables_path) + if variables_path.exists(): + logger.info(f" [green]✔[/green] Generated [cyan]{variables_file}[/cyan]") + else: + logger.error(f" [bold red]✘[/bold red] Failed to generate [cyan]{variables_file}[/cyan]") + except OSError as exc: + logger.error(f" [bold red]✘[/bold red] Failed to generate variables file: {exc}") + + +def _scaffold_project(project_path: Path, variables_path: Path, variables_file: str, tf_webapp_path: Path, cloud_provider: str) -> None: + """Create the full project directory structure and copy all template files.""" + try: + _create_project_dir(project_path) + _copy_yaml_templates(project_path, cloud_provider) + _create_postgres_jobs(project_path) + _copy_variables_template(variables_path, variables_file, cloud_provider) + _ensure_webapp(tf_webapp_path) + _print_success_summary(project_path, variables_file) + except OSError as exc: + logger.error(" [bold red]✘[/bold red] An error occurred while scaffolding see babylon logs for details") + logger.debug(f" [bold red]✘[/bold red] Error details: {exc}", exc_info=True) + + +def _create_project_dir(project_path: Path) -> None: + project_path.mkdir(parents=True, exist_ok=True) + if project_path.exists(): + logger.info(f" [dim]→ Created directory: {project_path}[/dim]") + else: + logger.error(f" [bold red]✘[/bold red] Failed to create directory: {project_path}") + + +def _copy_yaml_templates(project_path: Path, cloud_provider: str) -> None: + for filename in _PROJECT_YAML_FILES: + src = env.original_template_path / "yaml" / filename + copy(src, project_path / filename) + logger.info(f" [green]✔[/green] Generated [white]{filename}[/white]") + + # Copy the cloud-provider-specific Webapp.yaml + webapp_src = _get_provider_template(cloud_provider, "Webapp.yaml") + copy(webapp_src, project_path / "Webapp.yaml") + logger.info(f" [green]✔[/green] Generated [white]Webapp.yaml[/white] (provider: {cloud_provider})") + + +def _create_postgres_jobs(project_path: Path) -> None: + postgres_jobs_path = project_path / "postgres" / "jobs" + postgres_jobs_path.mkdir(parents=True, exist_ok=True) + if postgres_jobs_path.exists(): + logger.info(" [dim]→ Created directory: postgres/jobs[/dim]") + else: + logger.error(" [bold red]✘[/bold red] Failed to create directory: postgres/jobs") + + k8s_template = env.original_template_path / "yaml" / "k8s_job.yaml" + if k8s_template.exists(): + copy(k8s_template, postgres_jobs_path / "k8s_job.yaml") + logger.info(" [green]✔[/green] Generated [white]postgres/jobs/k8s_job.yaml[/white]") + + +def _copy_variables_template(variables_path: Path, variables_file: str, cloud_provider: str) -> None: + variables_template = _get_provider_template(cloud_provider, _VARIABLES_TEMPLATE) + copy(variables_template, variables_path) + if variables_path.exists(): + logger.info(f" [green]✔[/green] Generated [white]{variables_file}[/white] (provider: {cloud_provider})") + else: + logger.error(f" [bold red]✘[/bold red] Failed to generate [white]{variables_file}[/white]") + + +def _print_success_summary(project_path: Path, variables_file: str) -> None: + echo(style("\n🚀 Project successfully initialized!", fg="green", bold=True)) + echo(style(f" Path: {project_path}", fg="white", dim=True)) + echo(style("\nNext steps:", fg="white", bold=True)) + echo(style(f" 1. Edit your variables in {variables_file}", fg="cyan")) + echo(style(" 2. Run your first deployment command", fg="cyan")) @command() @option("--project-folder", default="project", help="Name of the project folder to create (default: 'project').") @option("--variables-file", default="variables.yaml", help="Name of the variables file (default: 'variables.yaml').") -def init(project_folder: str, variables_file: str): +@argument( + "cloud_provider", + type=Choice(['aws', 'azure', 'gcp', 'kob'], case_sensitive=False) +) +def init(project_folder: str, variables_file: str, cloud_provider: str): """ Scaffolds a new Babylon project structure using YAML templates. + + arguments: + + cloud_provider: Target cloud provider for webapp deployment (e.g., 'aws', 'azure', 'gcp', 'kob'). """ - project_path = Path(getcwd()) / project_folder - variables_path = Path(getcwd()) / variables_file + cwd = Path(getcwd()) + project_path = cwd / project_folder + variables_path = cwd / variables_file + tf_webapp_path = cwd / _TF_WEBAPP_DIR + + # Validation mode: project folder already exists — check each component. if project_path.exists(): - logger.warning(f"The directory [bold]{project_path}[/bold] already exists") - return None - if variables_path.exists(): - logger.warning(f"Configuration file [bold]{variables_file}[/bold] already exists.") + logger.info(f" [green]✔[/green] Project directory [cyan]{project_folder}[/cyan] already exists.") + _ensure_webapp(tf_webapp_path) + _ensure_variables_file(variables_path, variables_file, cloud_provider) return None - 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", - "Webapp.yaml", - ] - try: - # Create project directory - project_path.mkdir(parents=True, exist_ok=True) - logger.info(f" [dim]→ Created directory: {project_path}[/dim]") - # Copy Core YAML Templates - for file in project_yaml_files: - deploy_file = env.original_template_path / "yaml" / file - destination = project_path / file - copy(deploy_file, destination) - logger.info(f" [green]✔[/green] Generated [white]{file}[/white]") - - postgres_jobs_path = project_path / "postgres" / "jobs" - postgres_jobs_path.mkdir(parents=True, exist_ok=True) - k8s_template = env.original_template_path / "yaml" / "k8s_job.yaml" - if k8s_template.exists(): - copy(k8s_template, postgres_jobs_path / "k8s_job.yaml") - logger.info(" [green]✔[/green] Generated [white]postgres/jobs/k8s_job.yaml[/white]") - - variables_template = env.original_template_path / "yaml" / "variables.yaml" - copy(variables_template, variables_path) - logger.info(f" [green]✔[/green] Generated [white]{variables_file}[/white]") - - # --- 3. Success Summary --- - echo(style("\n🚀 Project successfully initialized!", fg="green", bold=True)) - echo(style(f" Path: {project_path}", fg="white", dim=True)) - echo(style("\nNext steps:", fg="white", bold=True)) - echo(style(f" 1. Edit your variables in {variables_file}", fg="cyan")) - echo(style(" 2. Run your first deployment command", fg="cyan")) - except Exception as e: - logger.error(" [bold red]✘[/bold red] An error occurred while scaffolding see babylon logs for details") - logger.debug(f" [bold red]✘[/bold red] Error details: {e}", exc_info=True) + + # Scaffold mode: nothing exists yet — build everything from scratch. + _scaffold_project(project_path, variables_path, variables_file, tf_webapp_path, cloud_provider) + + + diff --git a/Babylon/templates/working_dir/.templates/yaml/azure/Webapp.yaml b/Babylon/templates/working_dir/.templates/yaml/azure/Webapp.yaml new file mode 100644 index 00000000..8ca27c4a --- /dev/null +++ b/Babylon/templates/working_dir/.templates/yaml/azure/Webapp.yaml @@ -0,0 +1,14 @@ +kind: Webapp +namespace: + remote: false +spec: + payload: + cloud_provider: "azure" + cluster_name: "{{cluster_name}}" + domain_zone: "{{domain_zone}}" + 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/azure/variables.yaml b/Babylon/templates/working_dir/.templates/yaml/azure/variables.yaml new file mode 100644 index 00000000..571303e2 --- /dev/null +++ b/Babylon/templates/working_dir/.templates/yaml/azure/variables.yaml @@ -0,0 +1,43 @@ +# ========================================================= +# IMPORTANT: You can add variables here as needed! +# Make sure they are used in the manifest YAML. +# ========================================================= +# Organization +organization_name: Babylon v5 Organization +# Solution +solution_name: Babylon v5 Solution +simulator_repository: tenant-sphinx/brewerysamplesolution_simulator +solution_description: Brewery Testing babylon v5 Solution PLT +solution_key: breweryTesting +simulator_version: 3.0.0 +# Workspace +workspace_name: Babylon v5 workspace +workspace_key: brewerytestingwork +workspace_description: Testing workspace for the brewery web application +# Webapp +# These variables are used to render your Webapp.yaml manifest and can be mapped to the +# terraform module variables (terraform.tfvars) used by the terraform-webapp module. +# See the module's example tfvars for reference: +# https://github.com/Cosmo-Tech/terraform-webapp/blob/main/terraform.tfvars + +## VARIABLES EXAMPLE FOR AZURE +cloud_provider: azure +cluster_name: aks-dev-luxor +domain_zone: azure.platform.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 + accessControlList: + - id: tenant-admin + role: admin + - id: tenant-editor + role: editor + - id: tenant-viewer + role: viewer \ No newline at end of file diff --git a/Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml b/Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml new file mode 100644 index 00000000..f29b687f --- /dev/null +++ b/Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml @@ -0,0 +1,13 @@ +kind: Webapp +namespace: + remote: false +spec: + payload: + cloud_provider: "kob" + cluster_name: "{{cluster_name}}" + domain_zone: "{{domain_zone}}" + tenant: "{{tenant}}" + webapp_name: "{{webapp_name}}" + organization_id: "{{services['api.organization_id']}}" + state_host: "{{state_host}}" + powerbi_app_deploy: false \ No newline at end of file diff --git a/Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml b/Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml new file mode 100644 index 00000000..54fde9c5 --- /dev/null +++ b/Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml @@ -0,0 +1,41 @@ +# ========================================================= +# IMPORTANT: You can add variables here as needed! +# Make sure they are used in the manifest YAML. +# ========================================================= +# Organization +organization_name: Babylon v5 Organization +# Solution +solution_name: Babylon v5 Solution +simulator_repository: tenant-sphinx/brewerysamplesolution_simulator +solution_description: Brewery Testing babylon v5 Solution PLT +solution_key: breweryTesting +simulator_version: 3.0.0 +# Workspace +workspace_name: Babylon v5 workspace +workspace_key: brewerytestingwork +workspace_description: Testing workspace for the brewery web application +# Webapp +# These variables are used to render your Webapp.yaml manifest and can be mapped to the +# terraform module variables (terraform.tfvars) used by the terraform-webapp module. +# See the module's example tfvars for reference: +# https://github.com/Cosmo-Tech/terraform-webapp/blob/main/terraform.tfvars + +## VARIABLES EXAMPLE FOR KOB (= On-Premise) +cloud_provider: kob +cluster_name: kubernetes +domain_zone: onpremise.platform.cosmotech.com +tenant: test0 +webapp_name: business +organization_id: o-xxxxxxxxxxxx +state_host: https://cosmotechstates.onpremise.platform.cosmotech.com + +# ACL +security: + default: none + accessControlList: + - id: tenant-admin + role: admin + - id: tenant-editor + role: editor + - id: tenant-viewer + role: viewer \ No newline at end of file From 562fad8e5b025f313a63713402bea8a119754aee Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Tue, 31 Mar 2026 18:50:55 +0200 Subject: [PATCH 2/8] chore: delete unused template --- .../.templates/webapp/app_insight.json | 16 --------- .../.templates/webapp/webapp_config.yaml | 12 ------- .../.templates/webapp/webapp_details.yaml | 13 ------- .../working_dir/.templates/yaml/Webapp.yaml | 14 -------- .../.templates/yaml/dataset/customers.csv | 11 ------ .../.templates/yaml/variables.yaml | 36 ------------------- 6 files changed, 102 deletions(-) delete mode 100644 Babylon/templates/working_dir/.templates/webapp/app_insight.json delete mode 100644 Babylon/templates/working_dir/.templates/webapp/webapp_config.yaml delete mode 100644 Babylon/templates/working_dir/.templates/webapp/webapp_details.yaml delete mode 100644 Babylon/templates/working_dir/.templates/yaml/Webapp.yaml delete mode 100644 Babylon/templates/working_dir/.templates/yaml/dataset/customers.csv delete mode 100644 Babylon/templates/working_dir/.templates/yaml/variables.yaml diff --git a/Babylon/templates/working_dir/.templates/webapp/app_insight.json b/Babylon/templates/working_dir/.templates/webapp/app_insight.json deleted file mode 100644 index c3b928fa..00000000 --- a/Babylon/templates/working_dir/.templates/webapp/app_insight.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "kind": "web", - "location": "${cosmotech['webapp']['location']}", - "properties": { - "Application_Type": "web", - "DisableIpMasking": false, - "Flow_Type": "Bluefield", - "HockeyAppId": "", - "ImmediatePurgeDataOn30Days": false, - "IngestionMode": "ApplicationInsights", - "Request_Source": "rest", - "RetentionInDays": 90, - "SamplingPercentage": 100 - }, - "tags": "" -} diff --git a/Babylon/templates/working_dir/.templates/webapp/webapp_config.yaml b/Babylon/templates/working_dir/.templates/webapp/webapp_config.yaml deleted file mode 100644 index a02b03c8..00000000 --- a/Babylon/templates/working_dir/.templates/webapp/webapp_config.yaml +++ /dev/null @@ -1,12 +0,0 @@ -REACT_APP_APPLICATION_INSIGHTS_INSTRUMENTATION_KEY: "${cosmotech['webapp']['insights_instrumentation_key']}" -REACT_APP_ENABLE_APPLICATION_INSIGHTS: "${cosmotech['webapp']['enable_insights']}" -REACT_APP_APP_REGISTRATION_CLIENT_ID: "${cosmotech['app']['app_id']}" -REACT_APP_AZURE_TENANT_ID: "${cosmotech['azure']['tenant_id']}" -REACT_APP_COSMOTECH_API_SCOPE: "${cosmotech['api']['scope']}" -REACT_APP_DEFAULT_BASE_PATH: "${cosmotech['api']['url']}" -REACT_APP_ORGANIZATION_ID: "${cosmotech['api']['organization_id']}" -REACT_APP_WORKSPACES_IDS_FILTER: '' -REACT_APP_APP_VERSION: '' -REACT_APP_ORGANIZATION_URL: "${cosmotech['api']['organization_url']}" -REACT_APP_DOCUMENTATION_URL: https://cosmotech.com -REACT_APP_SUPPORT_URL: https://support.cosmotech.com diff --git a/Babylon/templates/working_dir/.templates/webapp/webapp_details.yaml b/Babylon/templates/working_dir/.templates/webapp/webapp_details.yaml deleted file mode 100644 index d424efac..00000000 --- a/Babylon/templates/working_dir/.templates/webapp/webapp_details.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: "{{webapp_name}}" -location: "{{webapp_location}}" -properties: - repositoryUrl: "{{webapp_repository}}" - branch: "{{webapp_branch}}" - repositoryToken: "{{github_secret}}" - buildProperties: - appLocation: "/" - apiLocation: api - appArtifactLocation: build -sku: - name: Standard - tier: Standard \ No newline at end of file diff --git a/Babylon/templates/working_dir/.templates/yaml/Webapp.yaml b/Babylon/templates/working_dir/.templates/yaml/Webapp.yaml deleted file mode 100644 index 8ca27c4a..00000000 --- a/Babylon/templates/working_dir/.templates/yaml/Webapp.yaml +++ /dev/null @@ -1,14 +0,0 @@ -kind: Webapp -namespace: - remote: false -spec: - payload: - cloud_provider: "azure" - cluster_name: "{{cluster_name}}" - domain_zone: "{{domain_zone}}" - 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/dataset/customers.csv b/Babylon/templates/working_dir/.templates/yaml/dataset/customers.csv deleted file mode 100644 index 5b2e65d0..00000000 --- a/Babylon/templates/working_dir/.templates/yaml/dataset/customers.csv +++ /dev/null @@ -1,11 +0,0 @@ -customerID,companyName,contactName,contactTitle,address,city,region,postalCode,country,phone,age -CUST001,Blueberry Tech,Alex Greenwood,CTO,1412 Pine St,Oakridge,CA,93561,USA,555-123-4560,10 -CUST002,Riverbend Logistics,Taylor Morris,Operations Lead,89 North Ave,Greendale,NY,11105,USA,555-987-6521,25 -CUST003,Sunrise Foods,Jordan Liu,Procurement Manager,220 Cherry Road,Springdale,TX,75010,USA,555-210-7895,34 -CUST004,Skyline Textiles,Maria Petrova,Sales Director,501 Textile Lane,Newpark,FL,33012,USA,555-313-9240,59 -CUST005,Crimson Solutions,Samuel Evans,CEO,77 Baker Blvd,Woodport,GA,30310,USA,555-825-2555,48 -CUST006,FastFix Auto,Isabelle Dubois,Service Manager,42 Garage Loop,Montréal,QC,H3Z 2Y7,Canada,514-601-3300,60 -CUST007,Garden Glow Ltd,Ellie Tan,Product Specialist,180 Daisy St,Burnside,NSW,2135,Australia,02-8000-1122,27 -CUST008,Evergreen Crafts,Jasper Lee,Owner,96 Willow Way,Lakeside,BC,V2V 4W1,Canada,604-800-9988,43 -CUST009,Peak Fitness Inc,Monica Anders,Marketing Lead,311 Summit Ave,Cascade,WA,98101,USA,206-777-5544,56 -CUST010,Silverline Media,Nikhil Ajay,Account Executive,845 Market Plaza,Redhill,ENG,RH1 6JT,UK,020-7123-4098,19 diff --git a/Babylon/templates/working_dir/.templates/yaml/variables.yaml b/Babylon/templates/working_dir/.templates/yaml/variables.yaml deleted file mode 100644 index d94b2f29..00000000 --- a/Babylon/templates/working_dir/.templates/yaml/variables.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# ========================================================= -# IMPORTANT: You can add variables here as needed! -# Make sure they are used in the manifest YAML. -# ========================================================= -# Organization -organization_name: Babylon v5 Organization -# Solution -solution_name: Babylon v5 Solution -simulator_repository: tenant-sphinx/brewerysamplesolution_simulator -solution_description: Brewery Testing babylon v5 Solution PLT -solution_key: breweryTesting -simulator_version: 3.0.0 -# Workspace -workspace_name: Babylon v5 workspace -workspace_key: brewerytestingwork -workspace_description: Testing workspace for the brewery web application -# Webapp -cloud_provider: azure -cluster_name: aks-dev-luxor -domain_zone: azure.platform.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 - accessControlList: - - id: tenant-admin - role: admin - - id: tenant-editor - role: editor - - id: tenant-viewer - role: viewer \ No newline at end of file From 2d112192e7d32389757475663e783759d7e108d7 Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Tue, 31 Mar 2026 18:55:07 +0200 Subject: [PATCH 3/8] feat: PROD-15569 --- Babylon/commands/namespace/get_contexts.py | 4 ++-- Babylon/utils/decorators.py | 19 +++---------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/Babylon/commands/namespace/get_contexts.py b/Babylon/commands/namespace/get_contexts.py index d72a6947..6cb7ac1d 100644 --- a/Babylon/commands/namespace/get_contexts.py +++ b/Babylon/commands/namespace/get_contexts.py @@ -13,8 +13,8 @@ def get_contexts() -> CommandResponse: """Display the currently active namespace""" namespace = env.get_namespace_from_local() - headers = ["CURRENT", "CONTEXT", "TENANT", "STATE ID"] - values = ["*", namespace.get("context", ""), namespace.get("tenant", ""), namespace.get("state_id", "")] + headers = ["CURRENT", "CONTEXT", "TENANT"] + values = ["*", namespace.get("context", ""), namespace.get("tenant", "")] col_widths = [max(len(h), len(v)) + 2 for h, v in zip(headers, values)] header_line = "".join(h.ljust(w) for h, w in zip(headers, col_widths)) value_line = "".join(v.ljust(w) for v, w in zip(values, col_widths)) diff --git a/Babylon/utils/decorators.py b/Babylon/utils/decorators.py index 6708c290..272bc9d4 100644 --- a/Babylon/utils/decorators.py +++ b/Babylon/utils/decorators.py @@ -188,12 +188,6 @@ def wrap_function(func: Callable[..., Any]) -> Callable[..., Any]: "tenant", help="Tenant Id without any special character", ) - @option( - "-s", - "--state-id", - "state_id", - help="State Id", - ) @wraps(func) def wrapper(*args: Any, **kwargs: Any): context = kwargs.pop("context", None) @@ -202,10 +196,7 @@ def wrapper(*args: Any, **kwargs: Any): tenant = kwargs.pop("tenant", None) if tenant and check_special_char(string=tenant): env.set_environ(tenant) - state_id = kwargs.pop("state_id", None) - if state_id and check_special_char(string=state_id): - env.set_state_id(state_id) - env.get_namespace_from_local(context=context, tenant=tenant, state_id=state_id) + env.get_namespace_from_local(context=context, tenant=tenant) return func(*args, **kwargs) return wrapper @@ -235,9 +226,8 @@ def wrapper(*args: Any, **kwargs: Any): def wrapcontext() -> Callable[..., Any]: def wrap_function(func: Callable[..., Any]) -> Callable[..., Any]: - @option("-c", "--context", "context", required=True, help="Context Name") - @option("-t", "--tenant", "tenant", required=True, help="Tenant Name") - @option("-s", "--state-id", "state_id", required=True, help="State Id") + @option("-c", "--context", "context", required=True, help="A unique identifier to isolate the project state (e.g., 'feature-x', 'prod-v1').") + @option("-t", "--tenant", "tenant", required=True, help="The tenant name (Kubernetes namespace) where the project will be deployed.") @wraps(func) def wrapper(*args: Any, **kwargs: Any): context = kwargs.pop("context", None) @@ -246,9 +236,6 @@ def wrapper(*args: Any, **kwargs: Any): tenant = kwargs.pop("tenant", None) if tenant and check_special_char(string=tenant): env.set_environ(tenant) - state_id = kwargs.pop("state_id", None) - if state_id and check_special_char(string=state_id): - env.set_state_id(state_id) return func(*args, **kwargs) return wrapper From 62343bca621a7dc6a12bf49cb976d9e4ba3d0692 Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Tue, 31 Mar 2026 18:57:22 +0200 Subject: [PATCH 4/8] feat: standardize state storage in Kubernetes instead of Azure for a multicloud perspective --- Babylon/commands/macro/deploy.py | 2 +- Babylon/commands/macro/deploy_organization.py | 2 +- Babylon/commands/macro/deploy_solution.py | 2 +- Babylon/commands/macro/deploy_workspace.py | 2 +- Babylon/commands/macro/destroy.py | 5 +- Babylon/utils/environment.py | 117 ++++-------- Babylon/utils/kubernetes_state.py | 176 ++++++++++++++++++ 7 files changed, 223 insertions(+), 83 deletions(-) create mode 100644 Babylon/utils/kubernetes_state.py diff --git a/Babylon/commands/macro/deploy.py b/Babylon/commands/macro/deploy.py index f8184644..53171173 100644 --- a/Babylon/commands/macro/deploy.py +++ b/Babylon/commands/macro/deploy.py @@ -405,4 +405,4 @@ def _finalize_deployment(payload, state): logger.info(f" [bold green]✔[/bold green] WebApp [bold white]{webapp_name}[/bold white] deployed") env.store_state_in_local(state) if env.remote: - env.store_state_in_cloud(state) + env.store_state_in_kubernetes(state) diff --git a/Babylon/commands/macro/deploy_organization.py b/Babylon/commands/macro/deploy_organization.py index 43589be9..ecf516ba 100644 --- a/Babylon/commands/macro/deploy_organization.py +++ b/Babylon/commands/macro/deploy_organization.py @@ -77,4 +77,4 @@ def deploy_organization(namespace: str, file_content: str): # Ensure the local and remote states are synchronized after successful API calls env.store_state_in_local(state) if env.remote: - env.store_state_in_cloud(state) + env.store_state_in_kubernetes(state) diff --git a/Babylon/commands/macro/deploy_solution.py b/Babylon/commands/macro/deploy_solution.py index bf004d55..ddf5df41 100644 --- a/Babylon/commands/macro/deploy_solution.py +++ b/Babylon/commands/macro/deploy_solution.py @@ -83,4 +83,4 @@ def deploy_solution(namespace: str, file_content: str) -> bool: # Ensure the local and remote states are synchronized after successful API calls env.store_state_in_local(state) if env.remote: - env.store_state_in_cloud(state) + env.store_state_in_kubernetes(state) diff --git a/Babylon/commands/macro/deploy_workspace.py b/Babylon/commands/macro/deploy_workspace.py index 5afdc641..75459f6b 100644 --- a/Babylon/commands/macro/deploy_workspace.py +++ b/Babylon/commands/macro/deploy_workspace.py @@ -222,4 +222,4 @@ def deploy_workspace(namespace: str, file_content: str, deploy_dir: PathlibPath) # Ensure the local and remote states are synchronized after successful API calls env.store_state_in_local(state) if env.remote: - env.store_state_in_cloud(state) + env.store_state_in_kubernetes(state) diff --git a/Babylon/commands/macro/destroy.py b/Babylon/commands/macro/destroy.py index fa35d10b..b12691a2 100644 --- a/Babylon/commands/macro/destroy.py +++ b/Babylon/commands/macro/destroy.py @@ -230,9 +230,8 @@ def destroy(state: dict, include: tuple[str], exclude: tuple[str]): # --- State Persistence --- env.store_state_in_local(state=state) if state.get("remote"): - logger.info(" [dim]☁ Syncing state cleanup to cloud...[/dim]") - env.set_blob_client() - env.store_state_in_cloud(state=state) + logger.info(" [dim]☁ Syncing state cleanup to kubernetes...[/dim]") + env.store_state_in_kubernetes(state=state) # --- Final Destruction Summary --- echo(style("\n📋 Destruction Summary", bold=True, fg="white")) diff --git a/Babylon/utils/environment.py b/Babylon/utils/environment.py index 8d1616f9..c624dc0b 100644 --- a/Babylon/utils/environment.py +++ b/Babylon/utils/environment.py @@ -6,7 +6,6 @@ from logging import getLogger from pathlib import Path -from azure.storage.blob import BlobServiceClient from flatten_json import flatten from kubernetes import client, config from kubernetes.client.exceptions import ApiException @@ -15,6 +14,7 @@ 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 get_state_from_kubernetes, store_state_in_kubernetes from Babylon.utils.working_dir import WorkingDir from Babylon.utils.yaml_utils import yaml_to_json @@ -53,7 +53,6 @@ def __init__(self): self.remote = False self.pwd = Path.cwd() self.blob_client = None - self.state_id: str = "" self.context_id: str = "" self.environ_id: str = "" self.server_id: str = "" @@ -71,10 +70,6 @@ def __init__(self): self.working_dir = WorkingDir(working_dir_path=self.pwd) self.variable_files: list[Path] = [] - def _get_state_blob_client(self, blob_name: str): - """Get a blob client for state management""" - return self.blob_client.get_blob_client(container=self.STATE_CONTAINER, blob=blob_name) - def get_variables(self): merged_data, duplicate_keys = self.merge_yaml_files(self.variable_files) if len(duplicate_keys) > 0: @@ -96,8 +91,6 @@ def get_ns_from_text(self, content: str): payload_dict = safe_load(payload) remote: bool = payload_dict.get("remote", self.remote) self.remote = remote - if remote: - self.set_blob_client() def fill_template(self, data: str, state: dict = None, ext_args: dict = None): result = data.replace("{{", "${").replace("}}", "}") @@ -119,26 +112,6 @@ def set_context(self, context_id): def set_environ(self, environ_id): self.environ_id = environ_id - def set_state_id(self, state_id: str): - self.state_id = state_id - - def set_blob_client(self): - try: - storage_name = os.getenv("STORAGE_NAME", "").strip() - account_secret = os.getenv("ACCOUNT_SECRET", "").strip() - if not storage_name and not account_secret: - raise EnvironmentError("Missing environment variables: 'STORAGE_NAME' and 'ACCOUNT_SECRET'") - connection_str = ( - f"DefaultEndpointsProtocol=https;" - f"AccountName={storage_name};" - f"AccountKey={account_secret};" - f"EndpointSuffix=core.windows.net" - ) - self.blob_client = BlobServiceClient.from_connection_string(connection_str) - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Failed to initialize BlobServiceClient: {e}") - sys.exit(1) - def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): response_parsed = {} try: @@ -155,7 +128,7 @@ def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): secret = v1.read_namespaced_secret(name=secret_name, namespace=tenant) except ApiException: logger.error("\n [bold red]✘[/bold red] Resource Not Found") - logger.error(f" Secret [green]{secret_name}[/green] could not be found in namespace [green]{tenant}[/green]") + logger.error(f" [yellow]⚠[/yellow] Secret [green]{secret_name}[/green] could not be found in namespace [green]{tenant}[/green].") # Show current kubectl context to help users spot a misconfiguration try: @@ -173,17 +146,13 @@ def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): ns_data = safe_load(ns_file.open("r").read()) or {} babylon_ctx = ns_data.get("context", "") babylon_tenant = ns_data.get("tenant", "") - babylon_state = ns_data.get("state_id", "") babylon_ns_info = ( f"context=[bold cyan]{babylon_ctx}[/bold cyan] " f"tenant=[bold cyan]{babylon_tenant}[/bold cyan] " - f"state-id=[bold cyan]{babylon_state}[/bold cyan]" ) except Exception: - babylon_ctx = babylon_tenant = babylon_state = "" babylon_ns_info = "[dim]unavailable[/dim]" else: - babylon_ctx = babylon_tenant = babylon_state = "" babylon_ns_info = "[dim]not set[/dim]" logger.info("\n [bold white]💡 Troubleshooting:[/bold white]") @@ -192,7 +161,7 @@ def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): logger.info(" • If the kubectl context is wrong, switch it:") logger.info(" [cyan]kubectl config use-context [/cyan]") logger.info(" • If the Babylon namespace is wrong, switch it:") - logger.info(" [cyan]babylon namespace use -c -t -s [/cyan]") + logger.info(" [cyan]babylon namespace use -c -t [/cyan]") sys.exit(1) except Exception: logger.error( @@ -209,39 +178,31 @@ def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): return response_parsed def store_state_in_local(self, state: dict): - state_file = f"state.{self.context_id}.{self.environ_id}.{self.state_id}.yaml" + state_file = f"state.{self.context_id}.{self.environ_id}.yaml" self.state_dir.mkdir(parents=True, exist_ok=True) s = self.state_dir / state_file - state["files"] = self.working_dir.files_to_deploy s.write_bytes(data=dump(state).encode("utf-8")) - def store_state_in_cloud(self, state: dict): - state_file = f"state.{self.context_id}.{self.environ_id}.{self.state_id}.yaml" - state_container = self.blob_client.get_container_client(container=self.STATE_CONTAINER) - if not state_container.exists(): - state_container.create_container() - state_blob = self._get_state_blob_client(state_file) - if state_blob.exists(): - state_blob.delete_blob() - state_blob.upload_blob(data=dump(state).encode("utf-8")) - - def list_remote_states(self) -> list[str]: - """List state file names present in the Azure blob container.""" - try: - self.set_blob_client() - container_client = self.blob_client.get_container_client(container=self.STATE_CONTAINER) - blobs = container_client.list_blobs(name_starts_with="state.") - return [b.name for b in blobs if b.name.endswith(".yaml")] - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Failed to list remote states: {e}") - return [] + 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}" + store_state_in_kubernetes(namespace=ns, secret_name=name, state_data=state) - def get_state_from_local(self): - state_file = self.state_dir / f"state.{self.context_id}.{self.environ_id}.{self.state_id}.yaml" - if not state_file.exists(): + def get_state_from_kubernetes(self, namespace: str = "", secret_name: str = "") -> dict: + """Retrieve state from a Kubernetes Secret. + + Returns the stored dictionary, or an empty default state when the + secret does not exist yet (mirrors the behaviour of + ``get_state_from_local`` and ``get_state_from_cloud``). + """ + ns = namespace or self.environ_id + name = secret_name or f"babylon-state-{self.context_id}-{self.environ_id}" + result = get_state_from_kubernetes(namespace=ns, secret_name=name) + if result is None: return { "context": self.context_id, - "id": self.state_id, "tenant": self.environ_id, "remote": self.remote, "services": { @@ -259,17 +220,24 @@ def get_state_from_local(self): }, }, } - state_data = load(state_file.open("r"), Loader=SafeLoader) - return state_data + return result + + # def list_remote_states(self) -> list[str]: + # """List state file names present in the Azure blob container.""" + # try: + # self.set_blob_client() + # container_client = self.blob_client.get_container_client(container=self.STATE_CONTAINER) + # blobs = container_client.list_blobs(name_starts_with="state.") + # return [b.name for b in blobs if b.name.endswith(".yaml")] + # except Exception as e: + # logger.error(f" [bold red]✘[/bold red] Failed to list remote states: {e}") + # return [] - def get_state_from_cloud(self) -> dict: - s = f"state.{self.context_id}.{self.environ_id}.{self.state_id}.yaml" - state_blob = self._get_state_blob_client(s) - exists = state_blob.exists() - if not exists: + def get_state_from_local(self): + state_file = self.state_dir / f"state.{self.context_id}.{self.environ_id}.yaml" + if not state_file.exists(): return { "context": self.context_id, - "id": self.state_id, "tenant": self.environ_id, "remote": self.remote, "services": { @@ -287,21 +255,20 @@ def get_state_from_cloud(self) -> dict: }, }, } - data = load(state_blob.download_blob().readall(), Loader=SafeLoader) - return data + state_data = load(state_file.open("r"), Loader=SafeLoader) + return state_data def store_namespace_in_local(self): ns_dir = self.state_dir if not ns_dir.exists(): ns_dir.mkdir(parents=True, exist_ok=True) s = ns_dir / "namespace.yaml" - ns = {"state_id": self.state_id, "context": self.context_id, "tenant": self.environ_id} + ns = {"context": self.context_id, "tenant": self.environ_id} s.write_bytes(data=dump(ns).encode("utf-8")) - self.set_state_id(state_id=self.state_id) self.set_context(context_id=self.context_id) self.set_environ(environ_id=self.environ_id) - def get_namespace_from_local(self, context: str = "", tenant: str = "", state_id: str = ""): + def get_namespace_from_local(self, context: str = "", tenant: str = ""): ns_file = self.state_dir / "namespace.yaml" if not ns_file.exists(): logger.error(f" [bold red]✘[/bold red] [cyan]{ns_file}[/cyan] not found") @@ -313,8 +280,6 @@ def get_namespace_from_local(self, context: str = "", tenant: str = "", state_id if ns_data: self.context_id = context or ns_data.get("context", "") self.environ_id = tenant or ns_data.get("tenant", "") - self.state_id = state_id or ns_data.get("state_id", "") - self.set_state_id(state_id=self.state_id) return ns_data def retrieve_config(self): @@ -340,7 +305,7 @@ def retrieve_config(self): def retrieve_state_func(self): if self.remote: - state = self.get_state_from_cloud() + state = self.get_state_from_kubernetes() else: state = self.get_state_from_local() return state diff --git a/Babylon/utils/kubernetes_state.py b/Babylon/utils/kubernetes_state.py new file mode 100644 index 00000000..379eabc1 --- /dev/null +++ b/Babylon/utils/kubernetes_state.py @@ -0,0 +1,176 @@ +""" +Cloud-agnostic Kubernetes Secret state management. + +Works with any Kubernetes cluster (AKS, EKS, GKE, KOB/on-prem, …) that is +reachable via the current kubeconfig context. + +Secret layout +───────────── + apiVersion: v1 + kind: Secret + type: Opaque + metadata: + name: + namespace: + data: + state.yaml: + +The single key inside the secret is always ``STATE_KEY`` ("state.yaml"). +""" + +import sys +from base64 import b64decode, b64encode +from logging import getLogger + +from kubernetes import client, config +from kubernetes.client.exceptions import ApiException +from kubernetes.config.config_exception import ConfigException +from yaml import dump, safe_load + +logger = getLogger(__name__) + +# The key name stored inside the Kubernetes Secret's data map. +STATE_KEY = "state.yaml" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _load_kube_config() -> None: + """Load kubeconfig, with a clear error message on failure.""" + try: + config.load_kube_config() + except ConfigException as exc: + logger.error("\n [bold red]✘[/bold red] Failed to load kube config") + logger.error(f" [red]Reason:[/red] {exc}") + logger.info("\n [bold white]💡 Troubleshooting:[/bold white]") + logger.info(" • Ensure your kubeconfig file is valid") + logger.info(" • Set your context: [cyan]kubectl config use-context [/cyan]") + sys.exit(1) + + +def _core_v1() -> client.CoreV1Api: + """Return a CoreV1Api instance (kubeconfig must already be loaded).""" + return client.CoreV1Api() + + +def _encode(data: dict) -> str: + """Serialise *data* to YAML and return a base64 string (utf-8).""" + yaml_str = dump(data, allow_unicode=True) + return b64encode(yaml_str.encode("utf-8")).decode("utf-8") + + +def _decode(raw: bytes | str) -> dict: + """Decode a base64 value coming from a Secret's ``data`` field. + + The kubernetes-client already base64-decodes ``data`` values when it + parses the API response, so *raw* may arrive as plain bytes or as a + base64 string depending on the client version. This helper handles + both cases gracefully. + """ + if isinstance(raw, (bytes, bytearray)): + yaml_str = raw.decode("utf-8") + else: + # Still base64-encoded (older client versions or raw JSON payload). + yaml_str = b64decode(raw).decode("utf-8") + return safe_load(yaml_str) or {} + + +def _build_secret(namespace: str, secret_name: str, encoded_value: str) -> client.V1Secret: + """Build a V1Secret object ready for create / replace calls.""" + return client.V1Secret( + api_version="v1", + kind="Secret", + type="Opaque", + metadata=client.V1ObjectMeta(name=secret_name, namespace=namespace), + data={STATE_KEY: encoded_value}, + ) + +# Public API + +def store_state_in_kubernetes(namespace: str, secret_name: str, state_data: dict) -> None: + """Persist *state_data* as a Kubernetes Secret in *namespace*. + + If the secret already exists it is **updated** (replaced) rather than + causing an error. The secret type is ``Opaque`` and the state is stored + under the key ``state.yaml`` as a base64-encoded YAML string. + """ + _load_kube_config() + v1 = _core_v1() + encoded = _encode(state_data) + secret = _build_secret(namespace, secret_name, encoded) + + try: + # Check whether the secret exists first. + v1.read_namespaced_secret(name=secret_name, namespace=namespace) + # Secret exists → replace it. + v1.replace_namespaced_secret(name=secret_name, namespace=namespace, body=secret) + logger.info( + f" [green]✔[/green] State secret [cyan]{secret_name}[/cyan] " + f"updated in namespace [cyan]{namespace}[/cyan]" + ) + except ApiException as exc: + if exc.status == 404: + # Secret does not exist → create it. + v1.create_namespaced_secret(namespace=namespace, body=secret) + logger.info( + f" [green]✔[/green] State secret [cyan]{secret_name}[/cyan] " + f"created in namespace [cyan]{namespace}[/cyan]" + ) + else: + logger.error( + f" [bold red]✘[/bold red] Kubernetes API error while storing state " + f"(HTTP {exc.status}): {exc.reason}" + ) + sys.exit(1) + except Exception as exc: + logger.error( + f" [bold red]✘[/bold red] Failed to connect to the Kubernetes cluster: {exc}" + ) + sys.exit(1) + + +def get_state_from_kubernetes(namespace: str, secret_name: str) -> dict | None: + """Read state from a Kubernetes Secret and return it as a dictionary. + + Returns ``None`` when the secret does not exist so the caller can decide + whether to initialise a fresh state or raise an error. + """ + _load_kube_config() + v1 = _core_v1() + + try: + secret = v1.read_namespaced_secret(name=secret_name, namespace=namespace) + except ApiException as exc: + if exc.status == 404: + logger.warning( + f" [yellow]⚠[/yellow] State secret [cyan]{secret_name}[/cyan] " + f"not found in namespace [cyan]{namespace}[/cyan]" + ) + return None + logger.error( + f" [bold red]✘[/bold red] Kubernetes API error while retrieving state " + f"(HTTP {exc.status}): {exc.reason}" + ) + sys.exit(1) + except Exception as exc: + logger.error( + f" [bold red]✘[/bold red] Failed to connect to the Kubernetes cluster: {exc}" + ) + sys.exit(1) + + if not secret.data or STATE_KEY not in secret.data: + logger.warning( + f" [yellow]⚠[/yellow] State secret [cyan]{secret_name}[/cyan] exists " + f"but contains no [cyan]{STATE_KEY}[/cyan] key" + ) + return None + + state = _decode(secret.data[STATE_KEY]) + logger.info( + f" [green]✔[/green] State loaded from secret [cyan]{secret_name}[/cyan] " + f"in namespace [cyan]{namespace}[/cyan]" + ) + return state From 69885ed54dc1875f2e835de650115491dd42a49e Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Wed, 1 Apr 2026 17:02:21 +0200 Subject: [PATCH 5/8] fix: kob webapp variables --- Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml | 1 + Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml b/Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml index f29b687f..900b9991 100644 --- a/Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml +++ b/Babylon/templates/working_dir/.templates/yaml/kob/Webapp.yaml @@ -8,6 +8,7 @@ spec: domain_zone: "{{domain_zone}}" tenant: "{{tenant}}" webapp_name: "{{webapp_name}}" + azure_entra_tenant_id: "{{azure_entra_tenant_id}}" organization_id: "{{services['api.organization_id']}}" state_host: "{{state_host}}" powerbi_app_deploy: false \ No newline at end of file diff --git a/Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml b/Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml index 54fde9c5..9b287554 100644 --- a/Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml +++ b/Babylon/templates/working_dir/.templates/yaml/kob/variables.yaml @@ -27,6 +27,7 @@ domain_zone: onpremise.platform.cosmotech.com tenant: test0 webapp_name: business organization_id: o-xxxxxxxxxxxx +azure_entra_tenant_id: e413b834-8be8-4822-a370-be619545cb49 state_host: https://cosmotechstates.onpremise.platform.cosmotech.com # ACL From 276598a230ecb84410df28d2d384f53ff7f03ed7 Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Wed, 1 Apr 2026 17:08:16 +0200 Subject: [PATCH 6/8] chore: refactor code for maintainability and SonarQube compliance --- Babylon/commands/macro/deploy.py | 3 + Babylon/commands/macro/deploy_workspace.py | 347 ++++++++++--------- Babylon/commands/macro/destroy.py | 91 ++--- Babylon/commands/namespace/get_all_states.py | 63 ++-- Babylon/utils/environment.py | 125 +++---- Babylon/utils/kubernetes_state.py | 9 +- 6 files changed, 331 insertions(+), 307 deletions(-) diff --git a/Babylon/commands/macro/deploy.py b/Babylon/commands/macro/deploy.py index 53171173..4fe2d0de 100644 --- a/Babylon/commands/macro/deploy.py +++ b/Babylon/commands/macro/deploy.py @@ -1,6 +1,7 @@ import subprocess from base64 import b64encode from logging import getLogger +from sys import exit from textwrap import dedent from click import Abort, echo, style @@ -384,6 +385,8 @@ def _run_terraform_process(executable, cwd, payload, state): color = next((status_colors[k] for k in status_colors if k in clean_line), "white") is_bold = color == "red" echo(style(f" {clean_line}", fg=color, bold=is_bold)) + if "Error" in clean_line or "error" in clean_line: + exit(1) if process.wait() == 0: _finalize_deployment(payload, state) diff --git a/Babylon/commands/macro/deploy_workspace.py b/Babylon/commands/macro/deploy_workspace.py index 75459f6b..35f7e082 100644 --- a/Babylon/commands/macro/deploy_workspace.py +++ b/Babylon/commands/macro/deploy_workspace.py @@ -1,5 +1,4 @@ import subprocess -from json import dumps from logging import getLogger from pathlib import Path as PathlibPath from string import Template @@ -28,198 +27,204 @@ env = Environment() +def _create_workspace(api_instance, api_section: dict, payload: dict, state: dict): + """Create a new workspace and persist its ID in state. Returns False on failure.""" + logger.info(" [dim]→ No existing workspace ID found. Creating...[/dim]") + workspace = api_instance.create_workspace( + organization_id=api_section["organization_id"], + workspace_create_request=WorkspaceCreateRequest.from_dict(payload), + ) + if workspace is None: + logger.error(" [bold red]✘[/bold red] Failed to create workspace") + return False + logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{workspace.id}[/bold magenta] created") + state["services"]["api"]["workspace_id"] = workspace.id + return True + + +def _sync_workspace_security(api_instance, api_section: dict, payload: dict): + """Synchronise security roles if a security block is present in the payload.""" + if not payload.get("security"): + return True + try: + logger.info(" [dim]→ Syncing security policies...[/dim]") + current_security = api_instance.get_workspace_security( + organization_id=api_section["organization_id"], workspace_id=api_section["workspace_id"] + ) + update_object_security( + "workspace", + current_security=current_security, + desired_security=WorkspaceSecurity.from_dict(payload.get("security")), + api_instance=api_instance, + object_id=[api_section["organization_id"], api_section["workspace_id"]], + ) + return True + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Security update failed: {e}") + return False + + +def _update_workspace(api_instance, api_section: dict, payload: dict): + """Update an existing workspace and sync its security policy. Returns False on failure.""" + logger.info(f" [dim]→ Existing ID [bold cyan]{api_section['workspace_id']}[/bold cyan] found. Updating...[/dim]") + updated = api_instance.update_workspace( + organization_id=api_section["organization_id"], + workspace_id=api_section["workspace_id"], + workspace_update_request=WorkspaceUpdateRequest.from_dict(payload), + ) + if updated is None: + logger.error(f" [bold red]✘[/bold red] Failed to update workspace {api_section['workspace_id']}") + return False + if not _sync_workspace_security(api_instance, api_section, payload): + return False + logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{api_section['workspace_id']}[/bold magenta] updated") + return True + + +def _handle_init_job_logs(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Fetch init-job logs and update state based on their content.""" + logs_process = subprocess.run( + ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], + capture_output=True, + text=True, + ) + if logs_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") + logger.debug(f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}") + return + + job_logs = logs_process.stdout or logs_process.stderr + if "ERROR" in job_logs or "error" in job_logs: + logger.error(" [bold red]✘[/bold red] Schema creation failed inside the container") + logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") + elif "already exists" in job_logs: + logger.info(f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta] already exists (skipping creation)[/dim]") + else: + logger.info(f" [green]✔[/green] Schema creation [magenta]{schema_name}[/magenta] completed successfully") + state["services"]["postgres"]["schema_name"] = schema_name + + +def _wait_and_check_init_job(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Wait for the init job to complete, then inspect its logs.""" + logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") + wait_process = subprocess.run( + ["kubectl", "wait", "--for=condition=complete", "job", k8s_job_name, f"--namespace={env.environ_id}", "--timeout=50s"], + capture_output=True, + text=True, + ) + if wait_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully see babylon logs for details") + logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") + return + logger.info(" [dim]→ Checking job logs for errors...[/dim]") + _handle_init_job_logs(k8s_job_name, schema_name, state) + + +def _run_schema_init_job(script_path: PathlibPath, mapping: dict, workspace_id: str, schema_name: str, state: dict) -> None: + """Apply a single K8s init job from *script_path* and wait for its outcome.""" + k8s_job_name = f"postgresql-init-{workspace_id}" + kube_config.load_kube_config() + k8s_client = client.ApiClient() + + with open(script_path, "r") as f: + raw_content = f.read() + + yaml_dict = safe_load(Template(raw_content).safe_substitute(mapping)) + try: + utils.create_from_dict(k8s_client, yaml_dict, namespace=env.environ_id) + _wait_and_check_init_job(k8s_job_name, schema_name, state) + except FailToCreateError as e: + for inner_exception in e.api_exceptions: + if inner_exception.status == 409: + logger.warning(f" [yellow]⚠[/yellow] [dim]Job [cyan]{k8s_job_name}[/cyan] already exists.[/dim]") + else: + logger.error(f" [bold red]✘[/bold red] K8s Error ({inner_exception.status}): {inner_exception.reason}") + logger.debug(f" Detail: {inner_exception.body}") + except Exception as e: + logger.error(" [bold red]✘[/bold red] Unexpected error please check babylon logs file for details") + logger.debug(f" [bold red]✘[/bold red] {e}") + + +def _deploy_postgres_schema(workspace_id: str, schema_config: dict, api_section: dict, deploy_dir: PathlibPath, state: dict) -> None: + """Initialise the PostgreSQL schema and create the associated K8s resources.""" + db_host = get_postgres_service_host(env.environ_id) + logger.info(f" [dim]→ Initializing PostgreSQL schema for workspace [bold cyan]{workspace_id}[/bold cyan]...[/dim]") + + pg_config = env.get_config_from_k8s_secret_by_tenant("postgresql-config", env.environ_id) + api_config = env.get_config_from_k8s_secret_by_tenant("postgresql-cosmotechapi", env.environ_id) + if not pg_config or not api_config: + return + + schema_name = workspace_id.replace("-", "_") + mapping = { + "namespace": env.environ_id, + "db_host": db_host, + "db_port": "5432", + "cosmotech_api_database": api_config.get("database-name", ""), + "cosmotech_api_admin_username": api_config.get("admin-username", ""), + "cosmotech_api_admin_password": api_config.get("admin-password", ""), + "cosmotech_api_writer_username": api_config.get("writer-username", ""), + "cosmotech_api_reader_username": api_config.get("reader-username", ""), + "workspace_schema": schema_name, + "job_name": workspace_id, + } + + deploy_dir = deploy_dir if isinstance(deploy_dir, PathlibPath) else PathlibPath(deploy_dir) + for job in schema_config.get("jobs", []): + script_path = deploy_dir / job.get("path", "") / job.get("name", "") + if script_path.exists(): + _run_schema_init_job(script_path, mapping, workspace_id, schema_name, state) + + organization_id = api_section["organization_id"] + logger.info(f" [dim]→ Creating workspace secret for [cyan]{workspace_id}[/cyan]...[/dim]") + create_workspace_secret( + namespace=env.environ_id, + organization_id=organization_id, + workspace_id=workspace_id, + writer_password=api_config.get("writer-password", ""), + ) + logger.info(f" [dim]→ Creating CoAL ConfigMap for [cyan]{workspace_id}[/cyan]...[/dim]") + create_coal_configmap( + namespace=env.environ_id, + organization_id=organization_id, + workspace_id=workspace_id, + db_host=db_host, + db_port="5432", + db_name=api_config.get("database-name", ""), + schema_name=schema_name, + writer_username=api_config.get("writer-username", ""), + ) + + def deploy_workspace(namespace: str, file_content: str, deploy_dir: PathlibPath) -> bool: echo(style(f"\n🚀 Deploying Workspace in namespace: {env.environ_id}", bold=True, fg="cyan")) - # Retrieve the state env.get_ns_from_text(content=namespace) state = env.retrieve_state_func() content = env.fill_template(data=file_content, state=state) - # Authentication and API client initialization keycloak_token, config = get_keycloak_token() payload: dict = content.get("spec").get("payload") api_section = state["services"]["api"] - # Determine if we are performing a Create or Update based on state api_section["workspace_id"] = payload.get("id") or api_section.get("workspace_id", "") - spec = {} - spec["payload"] = dumps(payload, indent=2, ensure_ascii=True) api_instance = get_workspace_api_instance(config=config, keycloak_token=keycloak_token) # --- Deployment Logic --- if not api_section["workspace_id"]: - # Case: New Workspace - logger.info(" [dim]→ No existing workspace ID found. Creating...[/dim]") - workspace_create_request = WorkspaceCreateRequest.from_dict(payload) - workspace = api_instance.create_workspace( - organization_id=api_section["organization_id"], workspace_create_request=workspace_create_request - ) - if workspace is None: - logger.error(" [bold red]✘[/bold red] Failed to create workspace") + if not _create_workspace(api_instance, api_section, payload, state): return CommandResponse.fail() - # Save the newly generated ID to state - logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{workspace.id}[/bold magenta] created") - state["services"]["api"]["workspace_id"] = workspace.id else: - # Case: Update Existing Workspace - logger.info(f" [dim]→ Existing ID [bold cyan]{api_section['workspace_id']}[/bold cyan] found. Updating...[/dim]") - workspace_update_request = WorkspaceUpdateRequest.from_dict(payload) - updated = api_instance.update_workspace( - organization_id=api_section["organization_id"], - workspace_id=api_section["workspace_id"], - workspace_update_request=workspace_update_request, - ) - if updated is None: - logger.error(f" [bold red]✘[/bold red] Failed to update workspace {api_section['workspace_id']}") + if not _update_workspace(api_instance, api_section, payload): return CommandResponse.fail() - # Handle Security Policy synchronization if provided in payload - if payload.get("security"): - try: - logger.info(" [dim]→ Syncing security policies...[/dim]") - current_security = api_instance.get_workspace_security( - organization_id=api_section["organization_id"], workspace_id=api_section["workspace_id"] - ) - update_object_security( - "workspace", - current_security=current_security, - desired_security=WorkspaceSecurity.from_dict(payload.get("security")), - api_instance=api_instance, - object_id=[api_section["organization_id"], api_section["workspace_id"]], - ) - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Security update failed: {e}") - return CommandResponse.fail() - logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{api_section['workspace_id']}[/bold magenta] updated") + + # --- PostgreSQL Schema --- workspace_id = state["services"]["api"]["workspace_id"] spec = content.get("spec") or {} - sidecars = spec.get("sidecars") or {} - postgres_section = sidecars.get("postgres") or {} - schema_config = postgres_section.get("schema") or {} - should_create_schema = schema_config.get("create", False) - if should_create_schema: - db_host = get_postgres_service_host(env.environ_id) - logger.info(f" [dim]→ Initializing PostgreSQL schema for workspace [bold cyan]{workspace_id}[/bold cyan]...[/dim]") - pg_config = env.get_config_from_k8s_secret_by_tenant("postgresql-config", env.environ_id) - api_config = env.get_config_from_k8s_secret_by_tenant("postgresql-cosmotechapi", env.environ_id) - if pg_config and api_config: - schema_name = f"{workspace_id.replace('-', '_')}" - mapping = { - "namespace": env.environ_id, - "db_host": db_host, - "db_port": "5432", - "cosmotech_api_database": api_config.get("database-name", ""), - "cosmotech_api_admin_username": api_config.get("admin-username", ""), - "cosmotech_api_admin_password": api_config.get("admin-password", ""), - "cosmotech_api_writer_username": api_config.get("writer-username", ""), - "cosmotech_api_reader_username": api_config.get("reader-username", ""), - "workspace_schema": schema_name, - "job_name": workspace_id, - } - jobs = schema_config.get("jobs", []) - if not isinstance(deploy_dir, PathlibPath): - deploy_dir = PathlibPath(deploy_dir) - for job in jobs: - script_path = deploy_dir / job.get("path", "") / job.get("name", "") - if script_path.exists(): - kube_config.load_kube_config() - k8s_client = client.ApiClient() - k8s_job_name = f"postgresql-init-{workspace_id}" - with open(script_path, "r") as f: - raw_content = f.read() - templated_yaml = Template(raw_content).safe_substitute(mapping) - yaml_dict = safe_load(templated_yaml) - try: - utils.create_from_dict(k8s_client, yaml_dict, namespace=env.environ_id) - logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") - wait_process = subprocess.run( - [ - "kubectl", - "wait", - "--for=condition=complete", - "job", - k8s_job_name, - f"--namespace={env.environ_id}", - "--timeout=50s", - ], - capture_output=True, - text=True, - ) - if wait_process.returncode != 0: - logger.error( - f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully" - f" see babylon logs for details" - ) - logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") - else: - # Job completed, now check the logs for error - logger.info(" [dim]→ Checking job logs for errors...[/dim]") - logs_process = subprocess.run( - ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], - capture_output=True, - text=True, - ) - if logs_process.returncode == 0: - job_logs = logs_process.stdout if logs_process.stdout else logs_process.stderr - if "ERROR" in job_logs or "error" in job_logs: - logger.error(" [bold red]✘[/bold red] Schema creation failed inside the container") - logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") - elif "already exists" in job_logs: - logger.info( - f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta]" - f" already exists (skipping creation)[/dim]" - ) - else: - logger.info( - f" [green]✔[/green] Schema creation [magenta]{schema_name}[/magenta] completed successfully" - ) - state["services"]["postgres"]["schema_name"] = schema_name - else: - logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") - logger.debug( - f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}" - ) - - except FailToCreateError as e: - for inner_exception in e.api_exceptions: - if inner_exception.status == 409: - logger.warning(f" [yellow]⚠[/yellow] [dim]Job [cyan]{k8s_job_name}[/cyan] already exists.[/dim]") - else: - logger.error( - f" [bold red]✘[/bold red] K8s Error ({inner_exception.status}): {inner_exception.reason}" - ) - logger.debug(f" Detail: {inner_exception.body}") - except Exception as e: - logger.error(" [bold red]✘[/bold red] Unexpected error please check babylon logs file for details") - logger.debug(f" [bold red]✘[/bold red] {e}") - - # --- Workspace Secret & CoAL ConfigMap --- - organization_id = api_section["organization_id"] - writer_username = api_config.get("writer-username", "") - writer_password = api_config.get("writer-password", "") - db_name = api_config.get("database-name", "") - - logger.info(f" [dim]→ Creating workspace secret for [cyan]{workspace_id}[/cyan]...[/dim]") - create_workspace_secret( - namespace=env.environ_id, - organization_id=organization_id, - workspace_id=workspace_id, - writer_password=writer_password, - ) - - logger.info(f" [dim]→ Creating CoAL ConfigMap for [cyan]{workspace_id}[/cyan]...[/dim]") - create_coal_configmap( - namespace=env.environ_id, - organization_id=organization_id, - workspace_id=workspace_id, - db_host=db_host, - db_port="5432", - db_name=db_name, - schema_name=schema_name, - writer_username=writer_username, - ) + schema_config = spec.get("sidecars", {}).get("postgres", {}).get("schema") or {} + if schema_config.get("create", False): + _deploy_postgres_schema(workspace_id, schema_config, api_section, deploy_dir, state) # --- State Persistence --- - # Ensure the local and remote states are synchronized after successful API calls env.store_state_in_local(state) if env.remote: env.store_state_in_kubernetes(state) diff --git a/Babylon/commands/macro/destroy.py b/Babylon/commands/macro/destroy.py index b12691a2..a840cc00 100644 --- a/Babylon/commands/macro/destroy.py +++ b/Babylon/commands/macro/destroy.py @@ -22,6 +22,47 @@ env = Environment() +def _handle_destroy_job_logs(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Fetch job logs and update state based on their content.""" + logs_process = subprocess.run( + ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], + capture_output=True, + text=True, + ) + if logs_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") + logger.debug(f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}") + return + + job_logs = logs_process.stdout or logs_process.stderr + if "ERROR" in job_logs or "error" in job_logs: + logger.error(" [bold red]✘[/bold red] Schema destruction failed inside the container") + logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") + elif "does not exist" in job_logs: + logger.info(f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta] does not exist (nothing to clean)[/dim]") + state["services"]["postgres"]["schema_name"] = "" + else: + logger.info(f" [green]✔[/green] Schema destruction [magenta]{schema_name}[/magenta] completed successfully") + state["services"]["postgres"]["schema_name"] = "" + + +def _wait_and_check_destroy_job(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Wait for the destroy job to complete, then inspect its logs.""" + logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") + wait_process = subprocess.run( + ["kubectl", "wait", "--for=condition=complete", "job", k8s_job_name, f"--namespace={env.environ_id}", "--timeout=300s"], + capture_output=True, + text=True, + ) + if wait_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully see babylon logs for details") + logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") + return + + logger.info(" [dim]→ Checking job logs for errors...[/dim]") + _handle_destroy_job_logs(k8s_job_name, schema_name, state) + + def _destroy_schema(schema_name: str, state: dict) -> None: """ Destroy PostgreSQL schema for a workspace. @@ -29,7 +70,8 @@ def _destroy_schema(schema_name: str, state: dict) -> None: if not schema_name: logger.warning(" [yellow]⚠[/yellow] [dim]No schema found ! skipping deletion[/dim]") return - workspace_id_tmp = f"{schema_name.replace('_', '-')}" + + workspace_id_tmp = schema_name.replace("_", "-") db_host = get_postgres_service_host(env.environ_id) logger.info(f" [dim]→ Destroying postgreSQL schema for workspace [bold cyan]{workspace_id_tmp}[/bold cyan]...[/dim]") @@ -56,6 +98,7 @@ def _destroy_schema(schema_name: str, state: dict) -> None: k8s_job_name = f"postgresql-destroy-{workspace_id_tmp}" kube_config.load_kube_config() k8s_client = client.ApiClient() + with open(destroy_jobs, "r") as f: raw_content = f.read() @@ -64,49 +107,7 @@ def _destroy_schema(schema_name: str, state: dict) -> None: logger.info(" [dim]→ Applying kubernetes destroy job...[/dim]") try: utils.create_from_dict(k8s_client, yaml_dict, namespace=env.environ_id) - logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") - wait_process = subprocess.run( - [ - "kubectl", - "wait", - "--for=condition=complete", - "job", - k8s_job_name, - f"--namespace={env.environ_id}", - "--timeout=300s", - ], - capture_output=True, - text=True, - ) - if wait_process.returncode == 0: - # Job completed, now check the logs for error - logger.info(" [dim]→ Checking job logs for errors...[/dim]") - logs_process = subprocess.run( - ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], - capture_output=True, - text=True, - ) - if logs_process.returncode == 0: - job_logs = logs_process.stdout if logs_process.stdout else logs_process.stderr - if "ERROR" in job_logs or "error" in job_logs: - logger.error(" [bold red]✘[/bold red] Schema destruction failed inside the container") - logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") - elif "does not exist" in job_logs: - logger.info( - f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta] does not exist (nothing to clean)[/dim]" - ) - state["services"]["postgres"]["schema_name"] = "" - else: - logger.info(f" [green]✔[/green] Schema destruction [magenta]{schema_name}[/magenta] completed successfully") - state["services"]["postgres"]["schema_name"] = "" - else: - logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") - logger.debug(f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}") - - else: - logger.error(f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully see babylon logs for details") - logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") - + _wait_and_check_destroy_job(k8s_job_name, schema_name, state) except Exception as e: logger.error(" [bold red]✘[/bold red] Unexpected error please check babylon logs file for details") logger.debug(f" [bold red]✘[/bold red] {e}") @@ -127,7 +128,7 @@ def _destroy_webapp(state: dict): return try: process = subprocess.Popen( - ["terraform", "destroy", "-auto-approve"], + ["terraform", "destroy", "-auto-approve", "-lock=false", "-input=false"], cwd=tf_dir, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, diff --git a/Babylon/commands/namespace/get_all_states.py b/Babylon/commands/namespace/get_all_states.py index 2c12029a..0d638937 100644 --- a/Babylon/commands/namespace/get_all_states.py +++ b/Babylon/commands/namespace/get_all_states.py @@ -9,38 +9,39 @@ env = Environment() +def _get_local_states() -> bool: + echo(style("\n 📂 Local States", bold=True, fg="cyan")) + if not env.state_dir.exists(): + logger.error(f" [bold red]✘[/bold red] Directory not found: [dim]{env.state_dir}[/dim]") + return False + local_files = sorted(env.state_dir.glob("state.*.yaml")) + if not local_files: + logger.warning(" [yellow]⚠[/yellow] No local state files found") + return False + for f in local_files: + echo(style(" • ", fg="green") + f.name) + return True + + +def _get_remote_states() -> bool: + echo(style("\n ☁️ Remote States", bold=True, fg="cyan")) + try: + remote_files = env.list_remote_states() + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to reach remote storage: {e}") + return False + if not remote_files: + logger.warning(" [yellow]⚠[/yellow] No remote states found") + return False + for name in sorted(remote_files): + echo(style(" • ", fg="green") + name) + return True + + @command() @argument("target", type=Choice(["local", "remote"], case_sensitive=False)) def get_states(target: str) -> CommandResponse: - """Display states from local machine or Azure remote storage.""" - - results_found = False - if target == "local": - echo(style("\n 📂 Local States", bold=True, fg="cyan")) - states_dir = env.state_dir - - if not states_dir.exists(): - logger.error(f" [bold red]✘[/bold red] Directory not found: [dim]{states_dir}[/dim]") - else: - local_files = sorted(states_dir.glob("state.*.yaml")) - if not local_files: - logger.warning(" [yellow]⚠[/yellow] No local state files found") - else: - for f in local_files: - echo(style(" • ", fg="green") + f.name) - results_found = True - - elif target == "remote": - echo(style("\n ☁️ Remote States", bold=True, fg="cyan")) - try: - remote_files = env.list_remote_states() - if not remote_files: - logger.warning(" [yellow]⚠[/yellow] No remote states found on Azure") - else: - for name in sorted(remote_files): - echo(style(" • ", fg="green") + name) - results_found = True - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Failed to reach Azure storage: {e}") - + """Display states from local machine or remote Kubernetes storage.""" + handlers = {"local": _get_local_states, "remote": _get_remote_states} + results_found = handlers[target]() return CommandResponse.success() if results_found else CommandResponse.fail() diff --git a/Babylon/utils/environment.py b/Babylon/utils/environment.py index c624dc0b..061ef357 100644 --- a/Babylon/utils/environment.py +++ b/Babylon/utils/environment.py @@ -14,7 +14,7 @@ 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 get_state_from_kubernetes, store_state_in_kubernetes +from Babylon.utils.kubernetes_state import STATE_LABEL_KEY, STATE_LABEL_VALUE, get_state_from_kubernetes, store_state_in_kubernetes from Babylon.utils.working_dir import WorkingDir from Babylon.utils.yaml_utils import yaml_to_json @@ -23,6 +23,7 @@ STORE_STRING = "datastore" TEMPLATES_STRING = "templates" PATH_SYMBOL = "%" +NAMESPACE_FILE = "namespace.yaml" class SingletonMeta(type): @@ -112,52 +113,39 @@ def set_context(self, context_id): def set_environ(self, environ_id): self.environ_id = environ_id - def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): - response_parsed = {} + def _get_active_kubectl_context(self) -> str: try: - config.load_kube_config() - except ConfigException as e: - logger.error("\n [bold red]✘[/bold red] Failed to load kube config") - logger.error(f" [red]Reason:[/red] {e}") - logger.info("\n [bold white]💡 Troubleshooting:[/bold white]") - logger.info(" • Ensure your kubeconfig file is valid") - logger.info(" • Set your context: [cyan]kubectl config use-context [/cyan]") - sys.exit(1) + from kubernetes.config.kube_config import list_kube_config_contexts + + _, active_context = list_kube_config_contexts() + return active_context["name"] if active_context else "unknown" + except Exception: + return "unknown" + + def _get_babylon_namespace_info(self) -> str: + ns_file = self.state_dir / NAMESPACE_FILE + if not ns_file.exists(): + return "[dim]not set[/dim]" + try: + ns_data = safe_load(ns_file.open("r").read()) or {} + babylon_ctx = ns_data.get("context", "") + babylon_tenant = ns_data.get("tenant", "") + return ( + f"context=[bold cyan]{babylon_ctx}[/bold cyan] " + f"tenant=[bold cyan]{babylon_tenant}[/bold cyan] " + ) + except Exception: + return "[dim]unavailable[/dim]" + + def _load_k8s_secret(self, secret_name: str, tenant: str): try: v1 = client.CoreV1Api() - secret = v1.read_namespaced_secret(name=secret_name, namespace=tenant) + return v1.read_namespaced_secret(name=secret_name, namespace=tenant) except ApiException: - logger.error("\n [bold red]✘[/bold red] Resource Not Found") logger.error(f" [yellow]⚠[/yellow] Secret [green]{secret_name}[/green] could not be found in namespace [green]{tenant}[/green].") - - # Show current kubectl context to help users spot a misconfiguration - try: - from kubernetes.config.kube_config import list_kube_config_contexts - - _, active_context = list_kube_config_contexts() - current_k8s_ctx = active_context["name"] if active_context else "unknown" - except Exception: - current_k8s_ctx = "unknown" - - # Show current Babylon namespace from local config - ns_file = self.state_dir / "namespace.yaml" - if ns_file.exists(): - try: - ns_data = safe_load(ns_file.open("r").read()) or {} - babylon_ctx = ns_data.get("context", "") - babylon_tenant = ns_data.get("tenant", "") - babylon_ns_info = ( - f"context=[bold cyan]{babylon_ctx}[/bold cyan] " - f"tenant=[bold cyan]{babylon_tenant}[/bold cyan] " - ) - except Exception: - babylon_ns_info = "[dim]unavailable[/dim]" - else: - babylon_ns_info = "[dim]not set[/dim]" - logger.info("\n [bold white]💡 Troubleshooting:[/bold white]") - logger.info(f" • Active kubectl context : [cyan]{current_k8s_ctx}[/cyan]") - logger.info(f" • Active Babylon namespace: {babylon_ns_info}") + logger.info(f" • Active kubectl context : [cyan]{self._get_active_kubectl_context()}[/cyan]") + logger.info(f" • Active Babylon namespace: {self._get_babylon_namespace_info()}") logger.info(" • If the kubectl context is wrong, switch it:") logger.info(" [cyan]kubectl config use-context [/cyan]") logger.info(" • If the Babylon namespace is wrong, switch it:") @@ -169,13 +157,25 @@ def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): "'Cluster may be down, kube-apiserver unreachable'" ) sys.exit(1) - if secret.data: - for key, value in secret.data.items(): - decoded_value = b64decode(value).decode("utf-8") - response_parsed[key] = decoded_value - else: + + def get_config_from_k8s_secret_by_tenant(self, secret_name: str, tenant: str): + try: + config.load_kube_config() + except ConfigException as e: + logger.error("\n [bold red]✘[/bold red] Failed to load kube config") + logger.error(f" [red]Reason:[/red] {e}") + logger.info("\n [bold white]💡 Troubleshooting:[/bold white]") + logger.info(" • Ensure your kubeconfig file is valid") + logger.info(" • Set your context: [cyan]kubectl config use-context [/cyan]") + sys.exit(1) + + secret = self._load_k8s_secret(secret_name, tenant) + + if not secret.data: logger.warning(f" [yellow]⚠[/yellow] Secret {secret_name} in namespace '{tenant}' has no data") - return response_parsed + return {} + + return {key: b64decode(value).decode("utf-8") for key, value in secret.data.items()} def store_state_in_local(self, state: dict): state_file = f"state.{self.context_id}.{self.environ_id}.yaml" @@ -222,16 +222,23 @@ def get_state_from_kubernetes(self, namespace: str = "", secret_name: str = "") } return result - # def list_remote_states(self) -> list[str]: - # """List state file names present in the Azure blob container.""" - # try: - # self.set_blob_client() - # container_client = self.blob_client.get_container_client(container=self.STATE_CONTAINER) - # blobs = container_client.list_blobs(name_starts_with="state.") - # return [b.name for b in blobs if b.name.endswith(".yaml")] - # except Exception as e: - # logger.error(f" [bold red]✘[/bold red] Failed to list remote states: {e}") - # return [] + def list_remote_states(self) -> list[str]: + """List state secret names matching 'babylon-state-*' in the current namespace. + + Uses a server-side label selector so only matching secrets are transferred + over the wire — no client-side filtering needed. + """ + try: + config.load_kube_config() + v1 = client.CoreV1Api() + secrets = v1.list_namespaced_secret( + namespace=self.environ_id, + label_selector=f"{STATE_LABEL_KEY}={STATE_LABEL_VALUE}", + ) + return [s.metadata.name for s in secrets.items] + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to list remote states: {e}") + return [] def get_state_from_local(self): state_file = self.state_dir / f"state.{self.context_id}.{self.environ_id}.yaml" @@ -262,14 +269,14 @@ def store_namespace_in_local(self): ns_dir = self.state_dir if not ns_dir.exists(): ns_dir.mkdir(parents=True, exist_ok=True) - s = ns_dir / "namespace.yaml" + s = ns_dir / NAMESPACE_FILE ns = {"context": self.context_id, "tenant": self.environ_id} s.write_bytes(data=dump(ns).encode("utf-8")) self.set_context(context_id=self.context_id) self.set_environ(environ_id=self.environ_id) def get_namespace_from_local(self, context: str = "", tenant: str = ""): - ns_file = self.state_dir / "namespace.yaml" + ns_file = self.state_dir / NAMESPACE_FILE if not ns_file.exists(): logger.error(f" [bold red]✘[/bold red] [cyan]{ns_file}[/cyan] not found") logger.info(" Run the following command to set your active namespace:") diff --git a/Babylon/utils/kubernetes_state.py b/Babylon/utils/kubernetes_state.py index 379eabc1..87de5bab 100644 --- a/Babylon/utils/kubernetes_state.py +++ b/Babylon/utils/kubernetes_state.py @@ -31,6 +31,9 @@ # The key name stored inside the Kubernetes Secret's data map. STATE_KEY = "state.yaml" +# Label applied to every Babylon state secret enables fast server-side listing. +STATE_LABEL_KEY = "app.kubernetes.io/managed-by" +STATE_LABEL_VALUE = "babylon-state" # --------------------------------------------------------------------------- @@ -84,7 +87,11 @@ def _build_secret(namespace: str, secret_name: str, encoded_value: str) -> clien api_version="v1", kind="Secret", type="Opaque", - metadata=client.V1ObjectMeta(name=secret_name, namespace=namespace), + metadata=client.V1ObjectMeta( + name=secret_name, + namespace=namespace, + labels={STATE_LABEL_KEY: STATE_LABEL_VALUE}, + ), data={STATE_KEY: encoded_value}, ) From 7c502dc8c378e0b1c981a8fffbb571439ca090cb Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Tue, 7 Apr 2026 13:25:53 +0200 Subject: [PATCH 7/8] refactor(babylon): improve SonarQube code quality and update outdated dependencies --- .github/workflows/ci.yml | 12 +- Babylon/commands/macro/apply.py | 2 +- Babylon/commands/macro/deploy.py | 411 -------------- Babylon/commands/macro/deploy_organization.py | 2 +- Babylon/commands/macro/deploy_solution.py | 2 +- Babylon/commands/macro/deploy_webapp.py | 6 +- Babylon/commands/macro/deploy_workspace.py | 193 +------ Babylon/commands/macro/destroy.py | 198 +------ Babylon/commands/macro/helpers/__init__.py | 0 Babylon/commands/macro/helpers/common.py | 140 +++++ Babylon/commands/macro/helpers/webapp.py | 168 ++++++ Babylon/commands/macro/helpers/workspace.py | 526 ++++++++++++++++++ Babylon/commands/macro/init.py | 16 +- .../terraform_cloud/tfc_variables_create.yaml | 24 - .../terraform_cloud/tfc_workspace_create.yaml | 10 - Babylon/utils/decorators.py | 16 +- Babylon/utils/environment.py | 12 +- Babylon/utils/kubernetes_state.py | 41 +- tests/e2e/test_e2e.sh | 5 +- tests/integration/test_api_endpoints.sh | 3 +- tests/unit/test_macro.py | 2 +- uv.lock | 326 +++++------ 22 files changed, 1069 insertions(+), 1046 deletions(-) delete mode 100644 Babylon/commands/macro/deploy.py create mode 100644 Babylon/commands/macro/helpers/__init__.py create mode 100644 Babylon/commands/macro/helpers/common.py create mode 100644 Babylon/commands/macro/helpers/webapp.py create mode 100644 Babylon/commands/macro/helpers/workspace.py delete mode 100644 Babylon/templates/working_dir/terraform_cloud/tfc_variables_create.yaml delete mode 100644 Babylon/templates/working_dir/terraform_cloud/tfc_workspace_create.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeff0db7..8756d5a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,5 @@ name: Continuous Integration -env: - STORAGE_NAME: ${{ secrets.STORAGE_NAME }} - ACCOUNT_SECRET: ${{ secrets.ACCOUNT_SECRET }} - on: workflow_dispatch: push: @@ -55,13 +51,15 @@ jobs: with: enable-cache: true - - uses: azure/login@v3 + - name: Azure login + uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - uses: azure/aks-set-context@v4 + - name: Azure AKS set context + uses: azure/aks-set-context@v4 with: cluster-name: "${{ vars.CLUSTER_NAME }}" resource-group: "${{ vars.CLUSTER_RESOURCE_GROUP }}" @@ -71,10 +69,12 @@ jobs: uv venv uv pip install . shell: bash + - name: Run integration tests working-directory: tests/integration/ run: ./test_api_endpoints.sh shell: bash + - name: End-to-end tests working-directory: tests/e2e/ run: ./test_e2e.sh diff --git a/Babylon/commands/macro/apply.py b/Babylon/commands/macro/apply.py index f27a74ed..c0f9827f 100644 --- a/Babylon/commands/macro/apply.py +++ b/Babylon/commands/macro/apply.py @@ -5,11 +5,11 @@ from click import argument, command, echo, option, style from yaml import safe_dump, safe_load -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.commands.macro.helpers.common import resolve_inclusion_exclusion from Babylon.utils.decorators import injectcontext from Babylon.utils.environment import Environment diff --git a/Babylon/commands/macro/deploy.py b/Babylon/commands/macro/deploy.py deleted file mode 100644 index 4fe2d0de..00000000 --- a/Babylon/commands/macro/deploy.py +++ /dev/null @@ -1,411 +0,0 @@ -import subprocess -from base64 import b64encode -from logging import getLogger -from sys import exit -from textwrap import dedent - -from click import Abort, echo, style -from cosmotech_api.models.organization_access_control import OrganizationAccessControl -from cosmotech_api.models.organization_security import OrganizationSecurity -from cosmotech_api.models.solution_access_control import SolutionAccessControl -from cosmotech_api.models.solution_security import SolutionSecurity -from cosmotech_api.models.workspace_access_control import WorkspaceAccessControl -from cosmotech_api.models.workspace_security import WorkspaceSecurity -from kubernetes import client, config - -from Babylon.utils.environment import Environment - -logger = getLogger(__name__) - -env = Environment() - -# Helper functions for workspace deployment - - -def validate_inclusion_exclusion( - include: tuple[str], - exclude: tuple[str], -) -> bool: - """Include and exclude command line options cannot be combined and should have correct spelling""" - if include and exclude: # cannot combine conflicting options - echo(style("\n ✘ Argument Conflict", fg="red", bold=True)) - logger.error(" Cannot use [bold]--include[/bold] and [bold]--exclude[/bold] at the same time") - raise Abort() - - 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)) - # List the errors - for item in invalid_items: - logger.error(f" • [yellow] {item}[/yellow] is not a valid resource type") - logger.error(f" Allowed values are: [cyan]{', '.join(allowed_values)}[/cyan]") - raise Abort() - return True - - -def resolve_inclusion_exclusion( - include: tuple[str], - exclude: tuple[str], -) -> tuple[bool, bool, bool]: - """Resolve command line include and exclude. - - Args: - include (tuple[str]): which objects to include in the deployment - exclude (tuple[str]): which objects to exclude from the deployment - - Raises: - ValueError: Error if incompatible options are provided - - Returns: - tuple[bool, bool, bool]: flags to include organization, solution, workspace - """ - validate_inclusion_exclusion(include, exclude) - 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 - webapp = "webapp" not in exclude - return (organization, solution, workspace, webapp) - - -def diff( - acl1: OrganizationAccessControl | WorkspaceAccessControl | SolutionAccessControl, - acl2: OrganizationAccessControl | WorkspaceAccessControl | SolutionAccessControl, -) -> tuple[list[str], list[str], list[str]]: - """Generate a diff between two access control lists""" - ids1 = [i.id for i in acl1] - roles1 = [i.role for i in acl1] - ids2 = [i.id for i in acl2] - roles2 = [i.role for i in acl2] - to_add = [item for item in ids2 if item not in ids1] - to_delete = [item for item in ids1 if item not in ids2] - to_update = [item for item in ids1 if item in ids2 and roles1[ids1.index(item)] != roles2[ids2.index(item)]] - return (to_add, to_delete, to_update) - - -def update_default_security( - object_type: str, - current_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, - desired_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, - api_instance, - object_id: str, -) -> None: - if desired_security.default != current_security.default: - try: - getattr(api_instance, f"update_{object_type}_default_security")(object_id, desired_security.default) - logger.info(f" [bold green]✔[/bold green] Updated [magenta]{object_type}[/magenta] default security") - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Failed to update [magenta]{object_type}[/magenta] default security: {e}") - - -def update_object_security( - object_type: str, - current_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, - desired_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, - api_instance, - object_id: list[str], -): - """Update object security: - if default security differs from payload - update object default security - diff state vs payload - foreach diff - delete entries to be removed - update entries to be changed - create entries to be added - """ - update_default_security(object_type, current_security, desired_security, api_instance, object_id) - (to_add, to_delete, to_update) = diff(current_security.access_control_list, desired_security.access_control_list) - for entry in desired_security.access_control_list: - if entry.id in to_add: - try: - getattr(api_instance, f"create_{object_type}_access_control")(*object_id, entry) - logger.info(f" [bold green]✔[/bold green] Access control for id [magenta]{entry.id}[/magenta] added successfully") - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Failed to add access control for id [magenta]{entry.id}[/magenta]: {e}") - if entry.id in to_update: - try: - getattr(api_instance, f"update_{object_type}_access_control")(*object_id, entry.id, {"role": entry.role}) - logger.info(f" [bold green]✔[/bold green] Access control for id [magenta]{entry.id}[/magenta] updated successfully") - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Failed to update access control for id [magenta]{entry.id}[/magenta]: {e}") - for entry_id in to_delete: - try: - getattr(api_instance, f"delete_{object_type}_access_control")(*object_id, entry_id) - logger.info(f" [bold green]✔[/bold green] Access control for id [magenta]{entry_id}[/magenta] deleted successfully") - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Failed to delete access control for id [magenta]{entry_id}[/magenta]: {e}") - - -# Helper functions for workspace deployment - - -def get_postgres_service_host(namespace: str) -> str: - """Discovers the PostgreSQL service name in a namespace to build its FQDN - - Note: This function assumes PostgreSQL is running within the same Kubernetes cluster. - External database clusters are not currently supported. - """ - try: - config.load_kube_config() - v1 = client.CoreV1Api() - services = v1.list_namespaced_service(namespace) - - for svc in services.items: - if "postgresql" in svc.metadata.name or svc.metadata.labels.get("app.kubernetes.io/name") == "postgresql": - logger.info(f" [dim]→ Found PostgreSQL service {svc.metadata.name}[/dim]") - return f"{svc.metadata.name}.{namespace}.svc.cluster.local" - - return f"postgresql.{namespace}.svc.cluster.local" - except Exception as e: - logger.warning(" [bold yellow]⚠[/bold yellow] Service discovery failed ! default will be used.") - logger.debug(f" Exception details: {e}", exc_info=True) - return f"postgresql.{namespace}.svc.cluster.local" - - -def create_workspace_secret( - namespace: str, - organization_id: str, - workspace_id: str, - writer_password: str, -) -> bool: - """Create a Kubernetes Secret for a workspace containing API and PostgreSQL credentials. - - The secret is named ``-`` and holds all - environment variables required by workspace. - - Returns: - bool: True if the secret was created or already exists, False on error. - """ - secret_name = f"{organization_id}-{workspace_id}" - data = { - "POSTGRES_USER_PASSWORD": writer_password, - } - encoded_data = {k: b64encode(v.encode("utf-8")).decode("utf-8") for k, v in data.items()} - - secret = client.V1Secret( - api_version="v1", - kind="Secret", - metadata=client.V1ObjectMeta(name=secret_name, namespace=namespace), - type="Opaque", - data=encoded_data, - ) - - try: - config.load_kube_config() - v1 = client.CoreV1Api() - v1.create_namespaced_secret(namespace=namespace, body=secret) - logger.info(f" [bold green]✔[/bold green] Secret [magenta]{secret_name}[/magenta] created") - return True - except client.ApiException as e: - if e.status == 409: - logger.warning(f" [yellow]⚠[/yellow] [dim]Secret [magenta]{secret_name}[/magenta] already exists[/dim]") - return True - logger.error(f" [bold red]✘[/bold red] Failed to create secret {secret_name}: {e.reason}") - return False - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Unexpected error creating secret {secret_name}") - logger.debug(f" Detail: {e}", exc_info=True) - return False - - -def create_coal_configmap( - namespace: str, - organization_id: str, - workspace_id: str, - db_host: str, - db_port: str, - db_name: str, - schema_name: str, - writer_username: str, -) -> bool: - """Create a CoAL ConfigMap for a workspace. - - The ConfigMap is named ``--coal-config`` and - contains a ``coal-config.toml`` key with Postgres output configuration. The - ``user_password`` value is deliberately set to the literal string - ``env.POSTGRES_USER_PASSWORD`` so that the CoAL runtime resolves it from the - environment at execution time. - - Returns: - bool: True if the ConfigMap was created or already exists, False on error. - """ - configmap_name = f"{organization_id}-{workspace_id}-coal-config" - coal_toml = dedent(f"""\ - [[outputs]] - type = "postgres" - [outputs.conf.postgres] - host = "{db_host}" - port = "{db_port}" - db_name = "{db_name}" - db_schema = "{schema_name}" - user_name = "{writer_username}" - user_password = "env.POSTGRES_USER_PASSWORD" - """) - - configmap = client.V1ConfigMap( - api_version="v1", - kind="ConfigMap", - metadata=client.V1ObjectMeta(name=configmap_name, namespace=namespace), - data={"coal-config.toml": coal_toml}, - ) - - try: - config.load_kube_config() - v1 = client.CoreV1Api() - v1.create_namespaced_config_map(namespace=namespace, body=configmap) - logger.info(f" [bold green]✔[/bold green] ConfigMap [magenta]{configmap_name}[/magenta] created") - return True - except client.ApiException as e: - if e.status == 409: - logger.warning(f" [yellow]⚠[/yellow] [dim]ConfigMap [magenta]{configmap_name}[/magenta] already exists[/dim]") - return True - logger.error(f" [bold red]✘[/bold red] Failed to create ConfigMap {configmap_name}: {e.reason}") - return False - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Unexpected error creating ConfigMap {configmap_name}") - logger.debug(f" Detail: {e}", exc_info=True) - return False - - -def delete_kubernetes_resources(namespace: str, organization_id: str, workspace_id: str) -> None: - """Delete the Workspace Secret and CoAL ConfigMap created during deployment. - - Targets: - - Secret: ``-`` - - ConfigMap: ``--coal-config`` - - If a resource is already gone (404), a warning is logged and execution - continues without error. - """ - secret_name = f"{organization_id}-{workspace_id}" - configmap_name = f"{organization_id}-{workspace_id}-coal-config" - - try: - config.load_kube_config() - v1 = client.CoreV1Api() - except Exception as e: - logger.error(" [bold red]✘[/bold red] Failed to initialise Kubernetes client") - logger.debug(f" Detail: {e}", exc_info=True) - return - - # --- Delete Secret --- - try: - logger.info(" [dim]→ Deleting workspace Secret ...[/dim]") - v1.delete_namespaced_secret(name=secret_name, namespace=namespace) - logger.info(f" [bold green]✔[/bold green] Secret [magenta]{secret_name}[/magenta] deleted") - except client.ApiException as e: - if e.status == 404: - logger.warning(" [yellow]⚠[/yellow] [dim]Secret not found (already deleted)[/dim]") - else: - logger.error(f" [bold red]✘[/bold red] Failed to delete secret {secret_name}: {e.reason}") - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Unexpected error deleting secret {secret_name}") - logger.debug(f" Detail: {e}", exc_info=True) - - # --- Delete ConfigMap --- - try: - logger.info(" [dim]→ Deleting workspace ConfigMap ...[/dim]") - v1.delete_namespaced_config_map(name=configmap_name, namespace=namespace) - logger.info(f" [bold green]✔[/bold green] ConfigMap [magenta]{configmap_name}[/magenta] deleted") - except client.ApiException as e: - if e.status == 404: - logger.warning(" [yellow]⚠[/yellow] [dim]ConfigMap not found (already deleted)[/dim]") - else: - logger.error(f" [bold red]✘[/bold red] Failed to delete ConfigMap {configmap_name}: {e.reason}") - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Unexpected error deleting ConfigMap {configmap_name}") - logger.debug(f" Detail: {e}", exc_info=True) - - -# Helper functions for web application deployment - - -def dict_to_tfvars(payload: dict) -> str: - """Convert a dictionary to Terraform HCL tfvars format (key = "value"). - - Currently handles simple data structures: - - Booleans: converted to lowercase (true/false) - - Numbers: integers and floats as-is - - Strings: wrapped in double quotes - - Note: Complex nested structures (lists, dicts) are not yet supported. - This is sufficient for current WebApp tfvars which only use simple scalar values. - - Args: - payload (dict): Dictionary with simple key-value pairs - - Returns: - str: Terraform HCL formatted variable assignments - """ - 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) - - -def _run_terraform_process(executable, cwd, payload, state): - """Helper function to reduce the size of the main function (Clean Code)""" - try: - process = subprocess.Popen(executable, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) - - # Color mapping to avoid if/else statements in the loop - status_colors = { - "Initializing": "white", - "Upgrading": "white", - "Finding": "white", - "Refreshing": "white", - "Success": "green", - "complete": "green", - "Resources:": "green", - "Error": "red", - "error": "red", - } - - for line in process.stdout: - clean_line = line.strip() - if not clean_line: - continue - - color = next((status_colors[k] for k in status_colors if k in clean_line), "white") - is_bold = color == "red" - echo(style(f" {clean_line}", fg=color, bold=is_bold)) - if "Error" in clean_line or "error" in clean_line: - exit(1) - - if process.wait() == 0: - _finalize_deployment(payload, state) - else: - logger.error(" [bold red]✘[/bold red] Deployment failed") - - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Execution error: {e}") - - -def _finalize_deployment(payload, state): - """Handles the update of the final state""" - webapp_name = payload.get("webapp_name") - url = f"https://{payload.get('cluster_name')}.{payload.get('domain_zone')}/tenant-{payload.get('tenant')}/webapp-{webapp_name}" - - services = state.setdefault("services", {}) - services["webapp"] = {"webapp_name": f"webapp-{webapp_name}", "webapp_url": url} - - logger.info(f" [bold green]✔[/bold green] WebApp [bold white]{webapp_name}[/bold white] deployed") - env.store_state_in_local(state) - if env.remote: - env.store_state_in_kubernetes(state) diff --git a/Babylon/commands/macro/deploy_organization.py b/Babylon/commands/macro/deploy_organization.py index ecf516ba..47d47e1d 100644 --- a/Babylon/commands/macro/deploy_organization.py +++ b/Babylon/commands/macro/deploy_organization.py @@ -7,7 +7,7 @@ from cosmotech_api.models.organization_update_request import OrganizationUpdateRequest from Babylon.commands.api.organization import get_organization_api_instance -from Babylon.commands.macro.deploy import update_object_security +from Babylon.commands.macro.helpers.common import update_object_security from Babylon.utils.credentials import get_keycloak_token from Babylon.utils.environment import Environment from Babylon.utils.response import CommandResponse diff --git a/Babylon/commands/macro/deploy_solution.py b/Babylon/commands/macro/deploy_solution.py index ddf5df41..5caea2bf 100644 --- a/Babylon/commands/macro/deploy_solution.py +++ b/Babylon/commands/macro/deploy_solution.py @@ -7,7 +7,7 @@ from cosmotech_api.models.solution_update_request import SolutionUpdateRequest from Babylon.commands.api.solution import get_solution_api_instance -from Babylon.commands.macro.deploy import update_object_security +from Babylon.commands.macro.helpers.common import update_object_security from Babylon.utils.credentials import get_keycloak_token from Babylon.utils.environment import Environment from Babylon.utils.response import CommandResponse diff --git a/Babylon/commands/macro/deploy_webapp.py b/Babylon/commands/macro/deploy_webapp.py index 3b525d51..f6cbb700 100644 --- a/Babylon/commands/macro/deploy_webapp.py +++ b/Babylon/commands/macro/deploy_webapp.py @@ -5,7 +5,7 @@ from click import echo, style -from Babylon.commands.macro.deploy import _run_terraform_process, dict_to_tfvars +from Babylon.commands.macro.helpers.webapp import dict_to_tfvars, run_terraform_process from Babylon.utils.environment import Environment logger = getLogger(__name__) @@ -47,7 +47,7 @@ def deploy_webapp(namespace: str, file_content: str): if content != updated_content: script_path.write_text(updated_content) if sys.platform == "linux": - os.chmod(script_path, 0o755) + os.chmod(script_path, 0o700) except IOError as e: logger.error(f" [bold red]✘[/bold red] Script modification failed: {e}") return @@ -59,4 +59,4 @@ def deploy_webapp(namespace: str, file_content: str): return logger.info(" [dim]→ Running Terraform deployment...[/dim]") - _run_terraform_process(executable, tf_dir, payload, state) + run_terraform_process(executable, tf_dir, payload, state) diff --git a/Babylon/commands/macro/deploy_workspace.py b/Babylon/commands/macro/deploy_workspace.py index 35f7e082..a502dfff 100644 --- a/Babylon/commands/macro/deploy_workspace.py +++ b/Babylon/commands/macro/deploy_workspace.py @@ -1,23 +1,13 @@ -import subprocess from logging import getLogger from pathlib import Path as PathlibPath -from string import Template from click import echo, style -from cosmotech_api.models.workspace_create_request import WorkspaceCreateRequest -from cosmotech_api.models.workspace_security import WorkspaceSecurity -from cosmotech_api.models.workspace_update_request import WorkspaceUpdateRequest -from kubernetes import client, utils -from kubernetes import config as kube_config -from kubernetes.utils import FailToCreateError -from yaml import safe_load from Babylon.commands.api.workspace import get_workspace_api_instance -from Babylon.commands.macro.deploy import ( - create_coal_configmap, - create_workspace_secret, - get_postgres_service_host, - update_object_security, +from Babylon.commands.macro.helpers.workspace import ( + create_workspace, + deploy_postgres_schema, + update_workspace, ) from Babylon.utils.credentials import get_keycloak_token from Babylon.utils.environment import Environment @@ -27,175 +17,6 @@ env = Environment() -def _create_workspace(api_instance, api_section: dict, payload: dict, state: dict): - """Create a new workspace and persist its ID in state. Returns False on failure.""" - logger.info(" [dim]→ No existing workspace ID found. Creating...[/dim]") - workspace = api_instance.create_workspace( - organization_id=api_section["organization_id"], - workspace_create_request=WorkspaceCreateRequest.from_dict(payload), - ) - if workspace is None: - logger.error(" [bold red]✘[/bold red] Failed to create workspace") - return False - logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{workspace.id}[/bold magenta] created") - state["services"]["api"]["workspace_id"] = workspace.id - return True - - -def _sync_workspace_security(api_instance, api_section: dict, payload: dict): - """Synchronise security roles if a security block is present in the payload.""" - if not payload.get("security"): - return True - try: - logger.info(" [dim]→ Syncing security policies...[/dim]") - current_security = api_instance.get_workspace_security( - organization_id=api_section["organization_id"], workspace_id=api_section["workspace_id"] - ) - update_object_security( - "workspace", - current_security=current_security, - desired_security=WorkspaceSecurity.from_dict(payload.get("security")), - api_instance=api_instance, - object_id=[api_section["organization_id"], api_section["workspace_id"]], - ) - return True - except Exception as e: - logger.error(f" [bold red]✘[/bold red] Security update failed: {e}") - return False - - -def _update_workspace(api_instance, api_section: dict, payload: dict): - """Update an existing workspace and sync its security policy. Returns False on failure.""" - logger.info(f" [dim]→ Existing ID [bold cyan]{api_section['workspace_id']}[/bold cyan] found. Updating...[/dim]") - updated = api_instance.update_workspace( - organization_id=api_section["organization_id"], - workspace_id=api_section["workspace_id"], - workspace_update_request=WorkspaceUpdateRequest.from_dict(payload), - ) - if updated is None: - logger.error(f" [bold red]✘[/bold red] Failed to update workspace {api_section['workspace_id']}") - return False - if not _sync_workspace_security(api_instance, api_section, payload): - return False - logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{api_section['workspace_id']}[/bold magenta] updated") - return True - - -def _handle_init_job_logs(k8s_job_name: str, schema_name: str, state: dict) -> None: - """Fetch init-job logs and update state based on their content.""" - logs_process = subprocess.run( - ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], - capture_output=True, - text=True, - ) - if logs_process.returncode != 0: - logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") - logger.debug(f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}") - return - - job_logs = logs_process.stdout or logs_process.stderr - if "ERROR" in job_logs or "error" in job_logs: - logger.error(" [bold red]✘[/bold red] Schema creation failed inside the container") - logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") - elif "already exists" in job_logs: - logger.info(f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta] already exists (skipping creation)[/dim]") - else: - logger.info(f" [green]✔[/green] Schema creation [magenta]{schema_name}[/magenta] completed successfully") - state["services"]["postgres"]["schema_name"] = schema_name - - -def _wait_and_check_init_job(k8s_job_name: str, schema_name: str, state: dict) -> None: - """Wait for the init job to complete, then inspect its logs.""" - logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") - wait_process = subprocess.run( - ["kubectl", "wait", "--for=condition=complete", "job", k8s_job_name, f"--namespace={env.environ_id}", "--timeout=50s"], - capture_output=True, - text=True, - ) - if wait_process.returncode != 0: - logger.error(f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully see babylon logs for details") - logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") - return - logger.info(" [dim]→ Checking job logs for errors...[/dim]") - _handle_init_job_logs(k8s_job_name, schema_name, state) - - -def _run_schema_init_job(script_path: PathlibPath, mapping: dict, workspace_id: str, schema_name: str, state: dict) -> None: - """Apply a single K8s init job from *script_path* and wait for its outcome.""" - k8s_job_name = f"postgresql-init-{workspace_id}" - kube_config.load_kube_config() - k8s_client = client.ApiClient() - - with open(script_path, "r") as f: - raw_content = f.read() - - yaml_dict = safe_load(Template(raw_content).safe_substitute(mapping)) - try: - utils.create_from_dict(k8s_client, yaml_dict, namespace=env.environ_id) - _wait_and_check_init_job(k8s_job_name, schema_name, state) - except FailToCreateError as e: - for inner_exception in e.api_exceptions: - if inner_exception.status == 409: - logger.warning(f" [yellow]⚠[/yellow] [dim]Job [cyan]{k8s_job_name}[/cyan] already exists.[/dim]") - else: - logger.error(f" [bold red]✘[/bold red] K8s Error ({inner_exception.status}): {inner_exception.reason}") - logger.debug(f" Detail: {inner_exception.body}") - except Exception as e: - logger.error(" [bold red]✘[/bold red] Unexpected error please check babylon logs file for details") - logger.debug(f" [bold red]✘[/bold red] {e}") - - -def _deploy_postgres_schema(workspace_id: str, schema_config: dict, api_section: dict, deploy_dir: PathlibPath, state: dict) -> None: - """Initialise the PostgreSQL schema and create the associated K8s resources.""" - db_host = get_postgres_service_host(env.environ_id) - logger.info(f" [dim]→ Initializing PostgreSQL schema for workspace [bold cyan]{workspace_id}[/bold cyan]...[/dim]") - - pg_config = env.get_config_from_k8s_secret_by_tenant("postgresql-config", env.environ_id) - api_config = env.get_config_from_k8s_secret_by_tenant("postgresql-cosmotechapi", env.environ_id) - if not pg_config or not api_config: - return - - schema_name = workspace_id.replace("-", "_") - mapping = { - "namespace": env.environ_id, - "db_host": db_host, - "db_port": "5432", - "cosmotech_api_database": api_config.get("database-name", ""), - "cosmotech_api_admin_username": api_config.get("admin-username", ""), - "cosmotech_api_admin_password": api_config.get("admin-password", ""), - "cosmotech_api_writer_username": api_config.get("writer-username", ""), - "cosmotech_api_reader_username": api_config.get("reader-username", ""), - "workspace_schema": schema_name, - "job_name": workspace_id, - } - - deploy_dir = deploy_dir if isinstance(deploy_dir, PathlibPath) else PathlibPath(deploy_dir) - for job in schema_config.get("jobs", []): - script_path = deploy_dir / job.get("path", "") / job.get("name", "") - if script_path.exists(): - _run_schema_init_job(script_path, mapping, workspace_id, schema_name, state) - - organization_id = api_section["organization_id"] - logger.info(f" [dim]→ Creating workspace secret for [cyan]{workspace_id}[/cyan]...[/dim]") - create_workspace_secret( - namespace=env.environ_id, - organization_id=organization_id, - workspace_id=workspace_id, - writer_password=api_config.get("writer-password", ""), - ) - logger.info(f" [dim]→ Creating CoAL ConfigMap for [cyan]{workspace_id}[/cyan]...[/dim]") - create_coal_configmap( - namespace=env.environ_id, - organization_id=organization_id, - workspace_id=workspace_id, - db_host=db_host, - db_port="5432", - db_name=api_config.get("database-name", ""), - schema_name=schema_name, - writer_username=api_config.get("writer-username", ""), - ) - - def deploy_workspace(namespace: str, file_content: str, deploy_dir: PathlibPath) -> bool: echo(style(f"\n🚀 Deploying Workspace in namespace: {env.environ_id}", bold=True, fg="cyan")) @@ -211,10 +32,10 @@ def deploy_workspace(namespace: str, file_content: str, deploy_dir: PathlibPath) # --- Deployment Logic --- if not api_section["workspace_id"]: - if not _create_workspace(api_instance, api_section, payload, state): + if not create_workspace(api_instance, api_section, payload, state): return CommandResponse.fail() else: - if not _update_workspace(api_instance, api_section, payload): + if not update_workspace(api_instance, api_section, payload): return CommandResponse.fail() # --- PostgreSQL Schema --- @@ -222,7 +43,7 @@ def deploy_workspace(namespace: str, file_content: str, deploy_dir: PathlibPath) spec = content.get("spec") or {} schema_config = spec.get("sidecars", {}).get("postgres", {}).get("schema") or {} if schema_config.get("create", False): - _deploy_postgres_schema(workspace_id, schema_config, api_section, deploy_dir, state) + deploy_postgres_schema(workspace_id, schema_config, api_section, deploy_dir, state) # --- State Persistence --- env.store_state_in_local(state) diff --git a/Babylon/commands/macro/destroy.py b/Babylon/commands/macro/destroy.py index a840cc00..7e18b583 100644 --- a/Babylon/commands/macro/destroy.py +++ b/Babylon/commands/macro/destroy.py @@ -1,18 +1,17 @@ -import subprocess from logging import getLogger -from pathlib import Path -from string import Template -from typing import Callable from click import command, echo, option, style -from kubernetes import client, utils -from kubernetes import config as kube_config -from yaml import safe_load from Babylon.commands.api.organization import get_organization_api_instance from Babylon.commands.api.solution import get_solution_api_instance from Babylon.commands.api.workspace import get_workspace_api_instance -from Babylon.commands.macro.deploy import delete_kubernetes_resources, get_postgres_service_host, resolve_inclusion_exclusion +from Babylon.commands.macro.helpers.common import resolve_inclusion_exclusion +from Babylon.commands.macro.helpers.webapp import destroy_webapp +from Babylon.commands.macro.helpers.workspace import ( + delete_api_resource, + delete_kubernetes_resources, + destroy_postgres_schema, +) from Babylon.utils.credentials import get_keycloak_token from Babylon.utils.decorators import injectcontext, retrieve_state from Babylon.utils.environment import Environment @@ -22,174 +21,6 @@ env = Environment() -def _handle_destroy_job_logs(k8s_job_name: str, schema_name: str, state: dict) -> None: - """Fetch job logs and update state based on their content.""" - logs_process = subprocess.run( - ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], - capture_output=True, - text=True, - ) - if logs_process.returncode != 0: - logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") - logger.debug(f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}") - return - - job_logs = logs_process.stdout or logs_process.stderr - if "ERROR" in job_logs or "error" in job_logs: - logger.error(" [bold red]✘[/bold red] Schema destruction failed inside the container") - logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") - elif "does not exist" in job_logs: - logger.info(f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta] does not exist (nothing to clean)[/dim]") - state["services"]["postgres"]["schema_name"] = "" - else: - logger.info(f" [green]✔[/green] Schema destruction [magenta]{schema_name}[/magenta] completed successfully") - state["services"]["postgres"]["schema_name"] = "" - - -def _wait_and_check_destroy_job(k8s_job_name: str, schema_name: str, state: dict) -> None: - """Wait for the destroy job to complete, then inspect its logs.""" - logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") - wait_process = subprocess.run( - ["kubectl", "wait", "--for=condition=complete", "job", k8s_job_name, f"--namespace={env.environ_id}", "--timeout=300s"], - capture_output=True, - text=True, - ) - if wait_process.returncode != 0: - logger.error(f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully see babylon logs for details") - logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") - return - - logger.info(" [dim]→ Checking job logs for errors...[/dim]") - _handle_destroy_job_logs(k8s_job_name, schema_name, state) - - -def _destroy_schema(schema_name: str, state: dict) -> None: - """ - Destroy PostgreSQL schema for a workspace. - """ - if not schema_name: - logger.warning(" [yellow]⚠[/yellow] [dim]No schema found ! skipping deletion[/dim]") - return - - workspace_id_tmp = schema_name.replace("_", "-") - db_host = get_postgres_service_host(env.environ_id) - logger.info(f" [dim]→ Destroying postgreSQL schema for workspace [bold cyan]{workspace_id_tmp}[/bold cyan]...[/dim]") - - pg_config = env.get_config_from_k8s_secret_by_tenant("postgresql-config", env.environ_id) - api_config = env.get_config_from_k8s_secret_by_tenant("postgresql-cosmotechapi", env.environ_id) - - if not pg_config or not api_config: - logger.error(" [bold red]✘[/bold red] Failed to retrieve postgreSQL configuration from secrets") - return - - mapping = { - "namespace": env.environ_id, - "db_host": db_host, - "db_port": "5432", - "cosmotech_api_database": api_config.get("database-name"), - "cosmotech_api_admin_username": api_config.get("admin-username"), - "cosmotech_api_admin_password": api_config.get("admin-password"), - "cosmotech_api_writer_username": api_config.get("writer-username"), - "cosmotech_api_reader_username": api_config.get("reader-username"), - "workspace_schema": schema_name, - "job_name": workspace_id_tmp, - } - destroy_jobs = env.original_template_path / "yaml" / "k8s_job_destroy.yaml" - k8s_job_name = f"postgresql-destroy-{workspace_id_tmp}" - kube_config.load_kube_config() - k8s_client = client.ApiClient() - - with open(destroy_jobs, "r") as f: - raw_content = f.read() - - templated_yaml = Template(raw_content).safe_substitute(mapping) - yaml_dict = safe_load(templated_yaml) - logger.info(" [dim]→ Applying kubernetes destroy job...[/dim]") - try: - utils.create_from_dict(k8s_client, yaml_dict, namespace=env.environ_id) - _wait_and_check_destroy_job(k8s_job_name, schema_name, state) - except Exception as e: - logger.error(" [bold red]✘[/bold red] Unexpected error please check babylon logs file for details") - logger.debug(f" [bold red]✘[/bold red] {e}") - - -def _destroy_webapp(state: dict): - """Terraform Destroy webapp""" - 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", "-lock=false", "-input=false"], - cwd=tf_dir, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - - line_handlers = { - "Destroy complete!": "green", - "Resources:": "green", - "Error": "red", - } - - for line in process.stdout: - clean_line = line.strip() - if not clean_line: - continue - - color = next((color for key, color in line_handlers.items() if key in clean_line), "white") - bold = color == "red" - echo(style(f" {clean_line}", fg=color, bold=bold)) - - 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 -): - """Helper to handle repetitive deletion logic and error handling.""" - if not resource_id: - logger.warning(f" [yellow]⚠[/yellow] [dim]No {resource_name} ID found in state! skipping deletion[dim]") - return - - try: - logger.info(f" [dim]→ Existing ID [bold cyan]{resource_id}[/bold cyan] found. Deleting...[/dim]") - if org_id and resource_name != "Organization": - api_call(organization_id=org_id, **{f"{resource_name.lower()}_id": resource_id}) - else: - api_call(organization_id=resource_id) - - logger.info(f" [bold green]✔[/bold green] {resource_name} [magenta]{resource_id}[/magenta] deleted") - state["services"]["api"][state_key] = "" - except Exception as 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() @injectcontext() @retrieve_state @@ -198,35 +29,33 @@ def _delete_resource( def destroy(state: dict, include: tuple[str], exclude: tuple[str]): """Macro Destroy""" 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() - # We need the Org ID for most sub-resource deletions api_state = state["services"]["api"] schema_state = state["services"]["postgres"] org_id = api_state["organization_id"] 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") + delete_api_resource(api.delete_solution, "Solution", org_id, api_state["solution_id"], state, "solution_id") if workspace: - _destroy_schema(schema_state["schema_name"], state) + 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"], ) api = get_workspace_api_instance(config=config, keycloak_token=keycloak_token) - _delete_resource(api.delete_workspace, "Workspace", org_id, api_state["workspace_id"], state, "workspace_id") + delete_api_resource(api.delete_workspace, "Workspace", org_id, api_state["workspace_id"], state, "workspace_id") if organization: api = get_organization_api_instance(config=config, keycloak_token=keycloak_token) - _delete_resource(api.delete_organization, "Organization", None, org_id, state, "organization_id") + delete_api_resource(api.delete_organization, "Organization", None, org_id, state, "organization_id") if webapp: - _destroy_webapp(state) + destroy_webapp(state) # --- State Persistence --- env.store_state_in_local(state=state) @@ -240,14 +69,11 @@ def destroy(state: dict, include: tuple[str], exclude: tuple[str]): services = final_state.get("services") api_data = services.get("api") for key, value in api_data.items(): - # Prepare the label (e.g., "Organization Id") label_text = f" • {key.replace('_', ' ').title()}" - # 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" 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" diff --git a/Babylon/commands/macro/helpers/__init__.py b/Babylon/commands/macro/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Babylon/commands/macro/helpers/common.py b/Babylon/commands/macro/helpers/common.py new file mode 100644 index 00000000..6a7e6624 --- /dev/null +++ b/Babylon/commands/macro/helpers/common.py @@ -0,0 +1,140 @@ +""" +Shared helpers used across all macro commands. + +Covers: +- ``include`` / ``exclude`` CLI option validation and resolution +- ACL diff computation +- Generic object-security synchronisation (organization, solution, workspace) +""" + +from logging import getLogger + +from click import Abort, echo, style +from cosmotech_api.models.organization_access_control import OrganizationAccessControl +from cosmotech_api.models.organization_security import OrganizationSecurity +from cosmotech_api.models.solution_access_control import SolutionAccessControl +from cosmotech_api.models.solution_security import SolutionSecurity +from cosmotech_api.models.workspace_access_control import WorkspaceAccessControl +from cosmotech_api.models.workspace_security import WorkspaceSecurity + +logger = getLogger(__name__) + + +def validate_inclusion_exclusion( + include: tuple[str], + exclude: tuple[str], +) -> bool: + """Include and exclude command line options cannot be combined and should have correct spelling.""" + if include and exclude: # cannot combine conflicting options + echo(style("\n ✘ Argument Conflict", fg="red", bold=True)) + logger.error(" Cannot use [bold]--include[/bold] and [bold]--exclude[/bold] at the same time") + raise Abort() + + 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)) + for item in invalid_items: + logger.error(f" • [yellow] {item}[/yellow] is not a valid resource type") + logger.error(f" Allowed values are: [cyan]{', '.join(allowed_values)}[/cyan]") + raise Abort() + return True + + +def resolve_inclusion_exclusion( + include: tuple[str], + exclude: tuple[str], +) -> tuple[bool, bool, bool, bool]: + """Resolve command line include and exclude. + + Args: + include (tuple[str]): which objects to include in the deployment + exclude (tuple[str]): which objects to exclude from the deployment + + Raises: + Abort: if incompatible or invalid options are provided + + Returns: + tuple[bool, bool, bool, bool]: flags to include organization, solution, workspace, webapp + """ + validate_inclusion_exclusion(include, exclude) + 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 + webapp = "webapp" not in exclude + return (organization, solution, workspace, webapp) + + +def diff( + acl1: OrganizationAccessControl | WorkspaceAccessControl | SolutionAccessControl, + acl2: OrganizationAccessControl | WorkspaceAccessControl | SolutionAccessControl, +) -> tuple[list[str], list[str], list[str]]: + """Generate a diff between two access control lists.""" + ids1 = [i.id for i in acl1] + roles1 = [i.role for i in acl1] + ids2 = [i.id for i in acl2] + roles2 = [i.role for i in acl2] + to_add = [item for item in ids2 if item not in ids1] + to_delete = [item for item in ids1 if item not in ids2] + to_update = [item for item in ids1 if item in ids2 and roles1[ids1.index(item)] != roles2[ids2.index(item)]] + return (to_add, to_delete, to_update) + + +def update_default_security( + object_type: str, + current_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, + desired_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, + api_instance, + object_id: str, +) -> None: + if desired_security.default != current_security.default: + try: + getattr(api_instance, f"update_{object_type}_default_security")(object_id, desired_security.default) + logger.info(f" [bold green]✔[/bold green] Updated [magenta]{object_type}[/magenta] default security") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to update [magenta]{object_type}[/magenta] default security: {e}") + + +def update_object_security( + object_type: str, + current_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, + desired_security: OrganizationSecurity | WorkspaceSecurity | SolutionSecurity, + api_instance, + object_id: list[str], +) -> None: + """Update object security: + - if default security differs from payload, update object default security + - diff state vs payload + - foreach diff: delete entries to be removed, update entries to be changed, create entries to be added + """ + update_default_security(object_type, current_security, desired_security, api_instance, object_id) + (to_add, to_delete, to_update) = diff(current_security.access_control_list, desired_security.access_control_list) + for entry in desired_security.access_control_list: + if entry.id in to_add: + try: + getattr(api_instance, f"create_{object_type}_access_control")(*object_id, entry) + logger.info(f" [bold green]✔[/bold green] Access control for id [magenta]{entry.id}[/magenta] added successfully") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to add access control for id [magenta]{entry.id}[/magenta]: {e}") + if entry.id in to_update: + try: + getattr(api_instance, f"update_{object_type}_access_control")(*object_id, entry.id, {"role": entry.role}) + logger.info(f" [bold green]✔[/bold green] Access control for id [magenta]{entry.id}[/magenta] updated successfully") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to update access control for id [magenta]{entry.id}[/magenta]: {e}") + for entry_id in to_delete: + try: + getattr(api_instance, f"delete_{object_type}_access_control")(*object_id, entry_id) + logger.info(f" [bold green]✔[/bold green] Access control for id [magenta]{entry_id}[/magenta] deleted successfully") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Failed to delete access control for id [magenta]{entry_id}[/magenta]: {e}") diff --git a/Babylon/commands/macro/helpers/webapp.py b/Babylon/commands/macro/helpers/webapp.py new file mode 100644 index 00000000..409316b9 --- /dev/null +++ b/Babylon/commands/macro/helpers/webapp.py @@ -0,0 +1,168 @@ +""" +Terraform helpers for WebApp deployment and teardown. + +Covers: +- Converting a payload dict to Terraform HCL tfvars format +- Running a Terraform subprocess and streaming its output +- Finalising state after a successful Terraform apply +- Destroying WebApp infrastructure via Terraform +""" + +import subprocess +from logging import getLogger +from pathlib import Path +from sys import exit + +from click import echo, style + +from Babylon.utils.environment import Environment + +logger = getLogger(__name__) +env = Environment() + + +def dict_to_tfvars(payload: dict) -> str: + """Convert a dictionary to Terraform HCL tfvars format (key = "value"). + + Currently handles simple data structures: + - Booleans: converted to lowercase (true/false) + - Numbers: integers and floats as-is + - Strings: wrapped in double quotes + + Note: Complex nested structures (lists, dicts) are not yet supported. + This is sufficient for current WebApp tfvars which only use simple scalar values. + + Args: + payload (dict): Dictionary with simple key-value pairs + + Returns: + str: Terraform HCL formatted variable assignments + """ + 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) + + +def _finalize_deployment(payload: dict, state: dict) -> None: + """Update state with the final WebApp name and URL after a successful Terraform apply.""" + webapp_name = payload.get("webapp_name") + url = f"https://{payload.get('cluster_name')}.{payload.get('domain_zone')}/tenant-{payload.get('tenant')}/webapp-{webapp_name}" + + services = state.setdefault("services", {}) + services["webapp"] = {"webapp_name": f"webapp-{webapp_name}", "webapp_url": url} + + logger.info(f" [bold green]✔[/bold green] WebApp [bold white]{webapp_name}[/bold white] deployed") + env.store_state_in_local(state) + if env.remote: + env.store_state_in_kubernetes(state) + + +def run_terraform_process(executable: list[str], cwd, payload: dict, state: dict) -> None: + """Stream a Terraform subprocess and finalize state on success. + + Args: + executable: The command + arguments to run (e.g. ``['/bin/bash', './_run-terraform.sh']``). + cwd: Working directory for the subprocess (path to the Terraform directory). + payload: The WebApp payload dict, forwarded to ``_finalize_deployment``. + state: Current Babylon state dict, forwarded to ``_finalize_deployment``. + """ + try: + process = subprocess.Popen(executable, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) + + # Color mapping to avoid if/else statements in the loop + status_colors = { + "Initializing": "white", + "Upgrading": "white", + "Finding": "white", + "Refreshing": "white", + "Success": "green", + "complete": "green", + "Resources:": "green", + "Error": "red", + "error": "red", + } + + for line in process.stdout: + clean_line = line.strip() + if not clean_line: + continue + + color = next((status_colors[k] for k in status_colors if k in clean_line), "white") + is_bold = color == "red" + echo(style(f" {clean_line}", fg=color, bold=is_bold)) + if "Error" in clean_line or "error" in clean_line: + exit(1) + + if process.wait() == 0: + _finalize_deployment(payload, state) + else: + logger.error(" [bold red]✘[/bold red] Deployment failed") + + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Execution error: {e}") + + +# --------------------------------------------------------------------------- +# WebApp teardown (used by destroy) +# --------------------------------------------------------------------------- + + +def destroy_webapp(state: dict) -> None: + """Run Terraform destroy to tear down WebApp infrastructure.""" + logger.info(" [dim]→ Running Terraform destroy for WebApp resources...[/dim]") + + tf_dir = Path(str(env.working_dir)).parent / "terraform-webapp" + + if not tf_dir.exists(): + logger.warning(f" [yellow]⚠[/yellow] [dim]Terraform directory not found at {tf_dir} skipping WebApp destroy[/dim]") + return + + # --- Check Terraform state, not Babylon state --- + tf_state_file = tf_dir / ".terraform" / "terraform.tfstate" + if not tf_state_file.exists(): + logger.warning(" [yellow]⚠[/yellow] [dim]No terraform state found, skipping WebApp destroy[/dim]") + return + + webapp_name = state.get("services", {}).get("webapp", {}).get("webapp_name") or "" + + try: + process = subprocess.Popen( + ["terraform", "destroy", "-auto-approve", "-lock=false", "-input=false"], + cwd=tf_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + line_handlers = { + "Destroy complete!": "green", + "Resources:": "green", + "Error": "red", + } + + for line in process.stdout: + clean_line = line.strip() + if not clean_line: + continue + color = next((color for key, color in line_handlers.items() if key in clean_line), "white") + bold = color == "red" + echo(style(f" {clean_line}", fg=color, bold=bold)) + + process.wait() + if process.returncode == 0: + state.setdefault("services", {}).setdefault("webapp", {}) + state["services"]["webapp"]["webapp_name"] = "" + state["services"]["webapp"]["webapp_url"] = "" + logger.info(f" [green]✔[/green] WebApp [magenta]{webapp_name}[/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}") diff --git a/Babylon/commands/macro/helpers/workspace.py b/Babylon/commands/macro/helpers/workspace.py new file mode 100644 index 00000000..7bde7044 --- /dev/null +++ b/Babylon/commands/macro/helpers/workspace.py @@ -0,0 +1,526 @@ +""" +Helpers for workspace deployment and teardown, organised by concern: + + 1. Cosmotech API — create / update workspace + security sync + — generic API resource deletion (used by destroy) + 2. Kubernetes resources — Secret and ConfigMap lifecycle (create / delete) + 3. Kubernetes PostgreSQL — service discovery + schema init-job orchestration + — schema teardown (used by destroy) +""" + +import subprocess +from base64 import b64encode +from logging import getLogger +from pathlib import Path as PathlibPath +from string import Template +from textwrap import dedent +from typing import Callable + +from cosmotech_api.models.workspace_create_request import WorkspaceCreateRequest +from cosmotech_api.models.workspace_security import WorkspaceSecurity +from cosmotech_api.models.workspace_update_request import WorkspaceUpdateRequest +from kubernetes import client, config, utils +from kubernetes import config as kube_config +from kubernetes.utils import FailToCreateError +from yaml import safe_load + +from Babylon.commands.macro.helpers.common import update_object_security +from Babylon.utils.environment import Environment + +logger = getLogger(__name__) +env = Environment() + + +# --------------------------------------------------------------------------- +# Cosmotech API helpers +# --------------------------------------------------------------------------- + + +def create_workspace(api_instance, api_section: dict, payload: dict, state: dict) -> bool: + """Create a new workspace and persist its ID in state. Returns False on failure.""" + logger.info(" [dim]→ No existing workspace ID found. Creating...[/dim]") + workspace = api_instance.create_workspace( + organization_id=api_section["organization_id"], + workspace_create_request=WorkspaceCreateRequest.from_dict(payload), + ) + if workspace is None: + logger.error(" [bold red]✘[/bold red] Failed to create workspace") + return False + logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{workspace.id}[/bold magenta] created") + state["services"]["api"]["workspace_id"] = workspace.id + return True + + +def sync_workspace_security(api_instance, api_section: dict, payload: dict) -> bool: + """Synchronise security roles if a security block is present in the payload.""" + if not payload.get("security"): + return True + try: + logger.info(" [dim]→ Syncing security policies...[/dim]") + current_security = api_instance.get_workspace_security( + organization_id=api_section["organization_id"], workspace_id=api_section["workspace_id"] + ) + update_object_security( + "workspace", + current_security=current_security, + desired_security=WorkspaceSecurity.from_dict(payload.get("security")), + api_instance=api_instance, + object_id=[api_section["organization_id"], api_section["workspace_id"]], + ) + return True + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Security update failed: {e}") + return False + + +def update_workspace(api_instance, api_section: dict, payload: dict) -> bool: + """Update an existing workspace and sync its security policy. Returns False on failure.""" + logger.info(f" [dim]→ Existing ID [bold cyan]{api_section['workspace_id']}[/bold cyan] found. Updating...[/dim]") + updated = api_instance.update_workspace( + organization_id=api_section["organization_id"], + workspace_id=api_section["workspace_id"], + workspace_update_request=WorkspaceUpdateRequest.from_dict(payload), + ) + if updated is None: + logger.error(f" [bold red]✘[/bold red] Failed to update workspace {api_section['workspace_id']}") + return False + if not sync_workspace_security(api_instance, api_section, payload): + return False + logger.info(f" [bold green]✔[/bold green] Workspace [bold magenta]{api_section['workspace_id']}[/bold magenta] updated") + return True + + +def delete_api_resource( + api_call: Callable[..., None], resource_name: str, org_id: str | None, resource_id: str, state: dict, state_key: str +) -> None: + """Delete a Cosmotech API resource and clear its ID from state. + + Handles the repetitive deletion pattern shared across organization, solution + and workspace teardown. A 404 response is treated as a no-op (already gone). + """ + if not resource_id: + logger.warning(f" [yellow]⚠[/yellow] [dim]No {resource_name} ID found in state! skipping deletion[dim]") + return + + try: + logger.info(f" [dim]→ Existing ID [bold cyan]{resource_id}[/bold cyan] found. Deleting...[/dim]") + if org_id and resource_name != "Organization": + api_call(organization_id=org_id, **{f"{resource_name.lower()}_id": resource_id}) + else: + api_call(organization_id=resource_id) + + logger.info(f" [bold green]✔[/bold green] {resource_name} [magenta]{resource_id}[/magenta] deleted") + state["services"]["api"][state_key] = "" + except Exception as 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}") + + +# --------------------------------------------------------------------------- +# Kubernetes Secret and ConfigMap lifecycle +# --------------------------------------------------------------------------- + + +def create_workspace_secret( + namespace: str, + organization_id: str, + workspace_id: str, + writer_password: str, +) -> bool: + """Create a Kubernetes Secret for a workspace containing API and PostgreSQL credentials. + + The secret is named ``-`` and holds all + environment variables required by workspace. + + Returns: + bool: True if the secret was created or already exists, False on error. + """ + secret_name = f"{organization_id}-{workspace_id}" + data = { + "POSTGRES_USER_PASSWORD": writer_password, + } + encoded_data = {k: b64encode(v.encode("utf-8")).decode("utf-8") for k, v in data.items()} + + secret = client.V1Secret( + api_version="v1", + kind="Secret", + metadata=client.V1ObjectMeta(name=secret_name, namespace=namespace), + type="Opaque", + data=encoded_data, + ) + + try: + config.load_kube_config() + v1 = client.CoreV1Api() + v1.create_namespaced_secret(namespace=namespace, body=secret) + logger.info(f" [bold green]✔[/bold green] Secret [magenta]{secret_name}[/magenta] created") + return True + except client.ApiException as e: + if e.status == 409: + logger.warning(f" [yellow]⚠[/yellow] [dim]Secret [magenta]{secret_name}[/magenta] already exists[/dim]") + return True + logger.error(f" [bold red]✘[/bold red] Failed to create secret {secret_name}: {e.reason}") + return False + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Unexpected error creating secret {secret_name}") + logger.debug(f" Detail: {e}", exc_info=True) + return False + + +def create_coal_configmap( + namespace: str, + organization_id: str, + workspace_id: str, + db_host: str, + db_port: str, + db_name: str, + schema_name: str, + writer_username: str, +) -> bool: + """Create a CoAL ConfigMap for a workspace. + + The ConfigMap is named ``--coal-config`` and + contains a ``coal-config.toml`` key with Postgres output configuration. The + ``user_password`` value is deliberately set to the literal string + ``env.POSTGRES_USER_PASSWORD`` so that the CoAL runtime resolves it from the + environment at execution time. + + Returns: + bool: True if the ConfigMap was created or already exists, False on error. + """ + configmap_name = f"{organization_id}-{workspace_id}-coal-config" + coal_toml = dedent(f"""\ + [[outputs]] + type = "postgres" + [outputs.conf.postgres] + host = "{db_host}" + port = "{db_port}" + db_name = "{db_name}" + db_schema = "{schema_name}" + user_name = "{writer_username}" + user_password = "env.POSTGRES_USER_PASSWORD" + """) + + configmap = client.V1ConfigMap( + api_version="v1", + kind="ConfigMap", + metadata=client.V1ObjectMeta(name=configmap_name, namespace=namespace), + data={"coal-config.toml": coal_toml}, + ) + + try: + config.load_kube_config() + v1 = client.CoreV1Api() + v1.create_namespaced_config_map(namespace=namespace, body=configmap) + logger.info(f" [bold green]✔[/bold green] ConfigMap [magenta]{configmap_name}[/magenta] created") + return True + except client.ApiException as e: + if e.status == 409: + logger.warning(f" [yellow]⚠[/yellow] [dim]ConfigMap [magenta]{configmap_name}[/magenta] already exists[/dim]") + return True + logger.error(f" [bold red]✘[/bold red] Failed to create ConfigMap {configmap_name}: {e.reason}") + return False + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Unexpected error creating ConfigMap {configmap_name}") + logger.debug(f" Detail: {e}", exc_info=True) + return False + + +def delete_kubernetes_resources(namespace: str, organization_id: str, workspace_id: str) -> None: + """Delete the Workspace Secret and CoAL ConfigMap created during deployment. + + Targets: + - Secret: ``-`` + - ConfigMap: ``--coal-config`` + + If a resource is already gone (404), a warning is logged and execution + continues without error. + """ + secret_name = f"{organization_id}-{workspace_id}" + configmap_name = f"{organization_id}-{workspace_id}-coal-config" + + try: + config.load_kube_config() + v1 = client.CoreV1Api() + except Exception as e: + logger.error(" [bold red]✘[/bold red] Failed to initialise Kubernetes client") + logger.debug(f" Detail: {e}", exc_info=True) + return + + # --- Delete Secret --- + try: + logger.info(" [dim]→ Deleting workspace Secret ...[/dim]") + v1.delete_namespaced_secret(name=secret_name, namespace=namespace) + logger.info(f" [bold green]✔[/bold green] Secret [magenta]{secret_name}[/magenta] deleted") + except client.ApiException as e: + if e.status == 404: + logger.warning(" [yellow]⚠[/yellow] [dim]Secret not found (already deleted)[/dim]") + else: + logger.error(f" [bold red]✘[/bold red] Failed to delete secret {secret_name}: {e.reason}") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Unexpected error deleting secret {secret_name}") + logger.debug(f" Detail: {e}", exc_info=True) + + # --- Delete ConfigMap --- + try: + logger.info(" [dim]→ Deleting workspace ConfigMap ...[/dim]") + v1.delete_namespaced_config_map(name=configmap_name, namespace=namespace) + logger.info(f" [bold green]✔[/bold green] ConfigMap [magenta]{configmap_name}[/magenta] deleted") + except client.ApiException as e: + if e.status == 404: + logger.warning(" [yellow]⚠[/yellow] [dim]ConfigMap not found (already deleted)[/dim]") + else: + logger.error(f" [bold red]✘[/bold red] Failed to delete ConfigMap {configmap_name}: {e.reason}") + except Exception as e: + logger.error(f" [bold red]✘[/bold red] Unexpected error deleting ConfigMap {configmap_name}") + logger.debug(f" Detail: {e}", exc_info=True) + + +# --------------------------------------------------------------------------- +# Kubernetes PostgreSQL service discovery +# --------------------------------------------------------------------------- + + +def get_postgres_service_host(namespace: str) -> str: + """Discover the PostgreSQL service name in a namespace to build its FQDN. + + Note: This function assumes PostgreSQL is running within the same Kubernetes cluster. + External database clusters are not currently supported. + """ + try: + config.load_kube_config() + v1 = client.CoreV1Api() + services = v1.list_namespaced_service(namespace) + + for svc in services.items: + if "postgresql" in svc.metadata.name or svc.metadata.labels.get("app.kubernetes.io/name") == "postgresql": + logger.info(f" [dim]→ Found PostgreSQL service {svc.metadata.name}[/dim]") + return f"{svc.metadata.name}.{namespace}.svc.cluster.local" + + return f"postgresql.{namespace}.svc.cluster.local" + except Exception as e: + logger.warning(" [bold yellow]⚠[/bold yellow] Service discovery failed ! default will be used.") + logger.debug(f" Exception details: {e}", exc_info=True) + return f"postgresql.{namespace}.svc.cluster.local" + + +# --------------------------------------------------------------------------- +# Kubernetes PostgreSQL schema init-job orchestration +# --------------------------------------------------------------------------- + + +def _handle_init_job_logs(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Fetch init-job logs and update state based on their content.""" + logs_process = subprocess.run( + ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], + capture_output=True, + text=True, + ) + if logs_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") + logger.debug(f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}") + return + + job_logs = logs_process.stdout or logs_process.stderr + if "ERROR" in job_logs or "error" in job_logs: + logger.error(" [bold red]✘[/bold red] Schema creation failed inside the container") + logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") + elif "already exists" in job_logs: + logger.info(f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta] already exists (skipping creation)[/dim]") + else: + logger.info(f" [green]✔[/green] Schema creation [magenta]{schema_name}[/magenta] completed successfully") + state["services"]["postgres"]["schema_name"] = schema_name + + +def _wait_and_check_init_job(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Wait for the init job to complete, then inspect its logs.""" + logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") + wait_process = subprocess.run( + ["kubectl", "wait", "--for=condition=complete", "job", k8s_job_name, f"--namespace={env.environ_id}", "--timeout=50s"], + capture_output=True, + text=True, + ) + if wait_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully see babylon logs for details") + logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") + return + logger.info(" [dim]→ Checking job logs for errors...[/dim]") + _handle_init_job_logs(k8s_job_name, schema_name, state) + + +def _run_schema_init_job(script_path: PathlibPath, mapping: dict, workspace_id: str, schema_name: str, state: dict) -> None: + """Apply a single K8s init job from *script_path* and wait for its outcome.""" + k8s_job_name = f"postgresql-init-{workspace_id}" + kube_config.load_kube_config() + k8s_client = client.ApiClient() + + with open(script_path, "r") as f: + raw_content = f.read() + + yaml_dict = safe_load(Template(raw_content).safe_substitute(mapping)) + try: + utils.create_from_dict(k8s_client, yaml_dict, namespace=env.environ_id) + _wait_and_check_init_job(k8s_job_name, schema_name, state) + except FailToCreateError as e: + for inner_exception in e.api_exceptions: + if inner_exception.status == 409: + logger.warning(f" [yellow]⚠[/yellow] [dim]Job [cyan]{k8s_job_name}[/cyan] already exists.[/dim]") + else: + logger.error(f" [bold red]✘[/bold red] K8s Error ({inner_exception.status}): {inner_exception.reason}") + logger.debug(f" Detail: {inner_exception.body}") + except Exception as e: + logger.error(" [bold red]✘[/bold red] Unexpected error please check babylon logs file for details") + logger.debug(f" [bold red]✘[/bold red] {e}") + + +def deploy_postgres_schema(workspace_id: str, schema_config: dict, api_section: dict, deploy_dir: PathlibPath, state: dict) -> None: + """Initialise the PostgreSQL schema and create the associated K8s resources.""" + db_host = get_postgres_service_host(env.environ_id) + logger.info(f" [dim]→ Initializing PostgreSQL schema for workspace [bold cyan]{workspace_id}[/bold cyan]...[/dim]") + + pg_config = env.get_config_from_k8s_secret_by_tenant("postgresql-config", env.environ_id) + api_config = env.get_config_from_k8s_secret_by_tenant("postgresql-cosmotechapi", env.environ_id) + if not pg_config or not api_config: + return + + schema_name = workspace_id.replace("-", "_") + mapping = { + "namespace": env.environ_id, + "db_host": db_host, + "db_port": "5432", + "cosmotech_api_database": api_config.get("database-name", ""), + "cosmotech_api_admin_username": api_config.get("admin-username", ""), + "cosmotech_api_admin_password": api_config.get("admin-password", ""), + "cosmotech_api_writer_username": api_config.get("writer-username", ""), + "cosmotech_api_reader_username": api_config.get("reader-username", ""), + "workspace_schema": schema_name, + "job_name": workspace_id, + } + + deploy_dir = deploy_dir if isinstance(deploy_dir, PathlibPath) else PathlibPath(deploy_dir) + for job in schema_config.get("jobs", []): + script_path = deploy_dir / job.get("path", "") / job.get("name", "") + if script_path.exists(): + _run_schema_init_job(script_path, mapping, workspace_id, schema_name, state) + + organization_id = api_section["organization_id"] + logger.info(f" [dim]→ Creating workspace secret for [cyan]{workspace_id}[/cyan]...[/dim]") + create_workspace_secret( + namespace=env.environ_id, + organization_id=organization_id, + workspace_id=workspace_id, + writer_password=api_config.get("writer-password", ""), + ) + logger.info(f" [dim]→ Creating CoAL ConfigMap for [cyan]{workspace_id}[/cyan]...[/dim]") + create_coal_configmap( + namespace=env.environ_id, + organization_id=organization_id, + workspace_id=workspace_id, + db_host=db_host, + db_port="5432", + db_name=api_config.get("database-name", ""), + schema_name=schema_name, + writer_username=api_config.get("writer-username", ""), + ) + + +# --------------------------------------------------------------------------- +# Kubernetes PostgreSQL schema teardown (used by destroy) +# --------------------------------------------------------------------------- + + +def _handle_destroy_job_logs(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Fetch destroy-job logs and update state based on their content.""" + logs_process = subprocess.run( + ["kubectl", "logs", f"job/{k8s_job_name}", "-n", env.environ_id], + capture_output=True, + text=True, + ) + if logs_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Failed to retrieve logs for job {k8s_job_name}") + logger.debug(f" [bold red]✘[/bold red] Logs retrieval output {logs_process.stdout} {logs_process.stderr}") + return + + job_logs = logs_process.stdout or logs_process.stderr + if "ERROR" in job_logs or "error" in job_logs: + logger.error(" [bold red]✘[/bold red] Schema destruction failed inside the container") + logger.debug(f" [bold red]✘[/bold red] Job logs : {job_logs}") + elif "does not exist" in job_logs: + logger.info(f" [yellow]⚠[/yellow] [dim]Schema [magenta]{schema_name}[/magenta] does not exist (nothing to clean)[/dim]") + state["services"]["postgres"]["schema_name"] = "" + else: + logger.info(f" [green]✔[/green] Schema destruction [magenta]{schema_name}[/magenta] completed successfully") + state["services"]["postgres"]["schema_name"] = "" + + +def _wait_and_check_destroy_job(k8s_job_name: str, schema_name: str, state: dict) -> None: + """Wait for the destroy job to complete, then inspect its logs.""" + logger.info(f" [dim]→ Waiting for job [cyan]{k8s_job_name}[/cyan] to complete...[/dim]") + wait_process = subprocess.run( + ["kubectl", "wait", "--for=condition=complete", "job", k8s_job_name, f"--namespace={env.environ_id}", "--timeout=300s"], + capture_output=True, + text=True, + ) + if wait_process.returncode != 0: + logger.error(f" [bold red]✘[/bold red] Job {k8s_job_name} did not complete successfully see babylon logs for details") + logger.debug(f" [bold red]✘[/bold red] Job wait output {wait_process.stdout} {wait_process.stderr}") + return + + logger.info(" [dim]→ Checking job logs for errors...[/dim]") + _handle_destroy_job_logs(k8s_job_name, schema_name, state) + + +def destroy_postgres_schema(schema_name: str, state: dict) -> None: + """Destroy the PostgreSQL schema for a workspace. + + Applies a K8s destroy job rendered from the template at + ``env.original_template_path / yaml / k8s_job_destroy.yaml``, waits for + it to complete and clears the schema name from state on success. + """ + if not schema_name: + logger.warning(" [yellow]⚠[/yellow] [dim]No schema found ! skipping deletion[/dim]") + return + + workspace_id_tmp = schema_name.replace("_", "-") + db_host = get_postgres_service_host(env.environ_id) + logger.info(f" [dim]→ Destroying postgreSQL schema for workspace [bold cyan]{workspace_id_tmp}[/bold cyan]...[/dim]") + + pg_config = env.get_config_from_k8s_secret_by_tenant("postgresql-config", env.environ_id) + api_config = env.get_config_from_k8s_secret_by_tenant("postgresql-cosmotechapi", env.environ_id) + + if not pg_config or not api_config: + logger.error(" [bold red]✘[/bold red] Failed to retrieve postgreSQL configuration from secrets") + return + + mapping = { + "namespace": env.environ_id, + "db_host": db_host, + "db_port": "5432", + "cosmotech_api_database": api_config.get("database-name"), + "cosmotech_api_admin_username": api_config.get("admin-username"), + "cosmotech_api_admin_password": api_config.get("admin-password"), + "cosmotech_api_writer_username": api_config.get("writer-username"), + "cosmotech_api_reader_username": api_config.get("reader-username"), + "workspace_schema": schema_name, + "job_name": workspace_id_tmp, + } + destroy_jobs = env.original_template_path / "yaml" / "k8s_job_destroy.yaml" + k8s_job_name = f"postgresql-destroy-{workspace_id_tmp}" + kube_config.load_kube_config() + k8s_client = client.ApiClient() + + with open(destroy_jobs, "r") as f: + raw_content = f.read() + + yaml_dict = safe_load(Template(raw_content).safe_substitute(mapping)) + logger.info(" [dim]→ Applying kubernetes destroy job...[/dim]") + try: + utils.create_from_dict(k8s_client, yaml_dict, namespace=env.environ_id) + _wait_and_check_destroy_job(k8s_job_name, schema_name, state) + except Exception as e: + logger.error(" [bold red]✘[/bold red] Unexpected error please check babylon logs file for details") + logger.debug(f" [bold red]✘[/bold red] {e}") diff --git a/Babylon/commands/macro/init.py b/Babylon/commands/macro/init.py index ad62ddb5..e082057c 100644 --- a/Babylon/commands/macro/init.py +++ b/Babylon/commands/macro/init.py @@ -4,7 +4,7 @@ from pathlib import Path from shutil import copy -from click import command, echo, option, style, argument, Choice +from click import Choice, argument, command, echo, option, style from Babylon.utils.environment import Environment @@ -28,6 +28,7 @@ # Private helpers + def _get_provider_template(cloud_provider: str, filename: str) -> Path: """Return the template path for *filename* scoped to *cloud_provider* when available, falling back to the shared yaml directory otherwise.""" @@ -82,7 +83,9 @@ def _ensure_variables_file(variables_path: Path, variables_file: str, cloud_prov logger.error(f" [bold red]✘[/bold red] Failed to generate variables file: {exc}") -def _scaffold_project(project_path: Path, variables_path: Path, variables_file: str, tf_webapp_path: Path, cloud_provider: str) -> None: +def _scaffold_project( + project_path: Path, variables_path: Path, variables_file: str, tf_webapp_path: Path, cloud_provider: str +) -> None: """Create the full project directory structure and copy all template files.""" try: _create_project_dir(project_path) @@ -146,13 +149,11 @@ def _print_success_summary(project_path: Path, variables_file: str) -> None: echo(style(f" 1. Edit your variables in {variables_file}", fg="cyan")) echo(style(" 2. Run your first deployment command", fg="cyan")) + @command() @option("--project-folder", default="project", help="Name of the project folder to create (default: 'project').") @option("--variables-file", default="variables.yaml", help="Name of the variables file (default: 'variables.yaml').") -@argument( - "cloud_provider", - type=Choice(['aws', 'azure', 'gcp', 'kob'], case_sensitive=False) -) +@argument("cloud_provider", type=Choice(["aws", "azure", "gcp", "kob"], case_sensitive=False)) def init(project_folder: str, variables_file: str, cloud_provider: str): """ Scaffolds a new Babylon project structure using YAML templates. @@ -175,6 +176,3 @@ def init(project_folder: str, variables_file: str, cloud_provider: str): # Scaffold mode: nothing exists yet — build everything from scratch. _scaffold_project(project_path, variables_path, variables_file, tf_webapp_path, cloud_provider) - - - diff --git a/Babylon/templates/working_dir/terraform_cloud/tfc_variables_create.yaml b/Babylon/templates/working_dir/terraform_cloud/tfc_variables_create.yaml deleted file mode 100644 index 3476453b..00000000 --- a/Babylon/templates/working_dir/terraform_cloud/tfc_variables_create.yaml +++ /dev/null @@ -1,24 +0,0 @@ -- key: var_key, - value: var_value, - description: var_description, - # category: var_category, - # hcl: var_hcl, - # sensitive: var_sensitive -- key: var_key, - value: var_value, - description: var_description, - # category: var_category, - # hcl: var_hcl, - # sensitive: var_sensitive -- key: var_key, - value: var_value, - description: var_description, - # category: var_category, - # hcl: var_hcl, - # sensitive: var_sensitive -- key: var_key, - value: var_value, - description: var_description, - # category: var_category, - # hcl: var_hcl, - # sensitive: var_sensitive \ No newline at end of file diff --git a/Babylon/templates/working_dir/terraform_cloud/tfc_workspace_create.yaml b/Babylon/templates/working_dir/terraform_cloud/tfc_workspace_create.yaml deleted file mode 100644 index 1bb6ff10..00000000 --- a/Babylon/templates/working_dir/terraform_cloud/tfc_workspace_create.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# workspace_name: name of the workspace -workspace_name: "" -# working_directory: working directory of the workspace -working_directory: "" -# vcs_identifier: identifier of the vcs associated to the workspace -vcs_identifier: "" -# vcs_branch: branch in the vcs used by the workspace -vcs_branch: "" -# vcs_oauth_token_id: id to the oauth token used to connect to the vcs -vcs_oauth_token_id: "" \ No newline at end of file diff --git a/Babylon/utils/decorators.py b/Babylon/utils/decorators.py index 272bc9d4..e661f6b0 100644 --- a/Babylon/utils/decorators.py +++ b/Babylon/utils/decorators.py @@ -226,8 +226,20 @@ def wrapper(*args: Any, **kwargs: Any): def wrapcontext() -> Callable[..., Any]: def wrap_function(func: Callable[..., Any]) -> Callable[..., Any]: - @option("-c", "--context", "context", required=True, help="A unique identifier to isolate the project state (e.g., 'feature-x', 'prod-v1').") - @option("-t", "--tenant", "tenant", required=True, help="The tenant name (Kubernetes namespace) where the project will be deployed.") + @option( + "-c", + "--context", + "context", + required=True, + help="A unique identifier to isolate the project state (e.g., 'feature-x', 'prod-v1').", + ) + @option( + "-t", + "--tenant", + "tenant", + required=True, + help="The tenant name (Kubernetes namespace) where the project will be deployed.", + ) @wraps(func) def wrapper(*args: Any, **kwargs: Any): context = kwargs.pop("context", None) diff --git a/Babylon/utils/environment.py b/Babylon/utils/environment.py index 061ef357..83baea4e 100644 --- a/Babylon/utils/environment.py +++ b/Babylon/utils/environment.py @@ -130,10 +130,7 @@ def _get_babylon_namespace_info(self) -> str: ns_data = safe_load(ns_file.open("r").read()) or {} babylon_ctx = ns_data.get("context", "") babylon_tenant = ns_data.get("tenant", "") - return ( - f"context=[bold cyan]{babylon_ctx}[/bold cyan] " - f"tenant=[bold cyan]{babylon_tenant}[/bold cyan] " - ) + return f"context=[bold cyan]{babylon_ctx}[/bold cyan] tenant=[bold cyan]{babylon_tenant}[/bold cyan] " except Exception: return "[dim]unavailable[/dim]" @@ -142,7 +139,9 @@ def _load_k8s_secret(self, secret_name: str, tenant: str): v1 = client.CoreV1Api() return v1.read_namespaced_secret(name=secret_name, namespace=tenant) except ApiException: - logger.error(f" [yellow]⚠[/yellow] Secret [green]{secret_name}[/green] could not be found in namespace [green]{tenant}[/green].") + logger.error( + f" [yellow]⚠[/yellow] Secret [green]{secret_name}[/green] could not be found in namespace [green]{tenant}[/green]." + ) logger.info("\n [bold white]💡 Troubleshooting:[/bold white]") logger.info(f" • Active kubectl context : [cyan]{self._get_active_kubectl_context()}[/cyan]") logger.info(f" • Active Babylon namespace: {self._get_babylon_namespace_info()}") @@ -184,8 +183,7 @@ def store_state_in_local(self, state: dict): s.write_bytes(data=dump(state).encode("utf-8")) def store_state_in_kubernetes(self, state: dict, namespace: str = "", secret_name: str = "") -> None: - """Persist *state* as a Kubernetes Secret. - """ + """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}" store_state_in_kubernetes(namespace=ns, secret_name=name, state_data=state) diff --git a/Babylon/utils/kubernetes_state.py b/Babylon/utils/kubernetes_state.py index 87de5bab..cb890cde 100644 --- a/Babylon/utils/kubernetes_state.py +++ b/Babylon/utils/kubernetes_state.py @@ -95,8 +95,10 @@ def _build_secret(namespace: str, secret_name: str, encoded_value: str) -> clien data={STATE_KEY: encoded_value}, ) + # Public API + def store_state_in_kubernetes(namespace: str, secret_name: str, state_data: dict) -> None: """Persist *state_data* as a Kubernetes Secret in *namespace*. @@ -114,28 +116,17 @@ def store_state_in_kubernetes(namespace: str, secret_name: str, state_data: dict v1.read_namespaced_secret(name=secret_name, namespace=namespace) # Secret exists → replace it. v1.replace_namespaced_secret(name=secret_name, namespace=namespace, body=secret) - logger.info( - f" [green]✔[/green] State secret [cyan]{secret_name}[/cyan] " - f"updated in namespace [cyan]{namespace}[/cyan]" - ) + logger.info(f" [green]✔[/green] State secret [cyan]{secret_name}[/cyan] updated in namespace [cyan]{namespace}[/cyan]") except ApiException as exc: if exc.status == 404: # Secret does not exist → create it. v1.create_namespaced_secret(namespace=namespace, body=secret) - logger.info( - f" [green]✔[/green] State secret [cyan]{secret_name}[/cyan] " - f"created in namespace [cyan]{namespace}[/cyan]" - ) + logger.info(f" [green]✔[/green] State secret [cyan]{secret_name}[/cyan] created in namespace [cyan]{namespace}[/cyan]") else: - logger.error( - f" [bold red]✘[/bold red] Kubernetes API error while storing state " - f"(HTTP {exc.status}): {exc.reason}" - ) + logger.error(f" [bold red]✘[/bold red] Kubernetes API error while storing state (HTTP {exc.status}): {exc.reason}") sys.exit(1) except Exception as exc: - logger.error( - f" [bold red]✘[/bold red] Failed to connect to the Kubernetes cluster: {exc}" - ) + logger.error(f" [bold red]✘[/bold red] Failed to connect to the Kubernetes cluster: {exc}") sys.exit(1) @@ -153,31 +144,21 @@ def get_state_from_kubernetes(namespace: str, secret_name: str) -> dict | None: except ApiException as exc: if exc.status == 404: logger.warning( - f" [yellow]⚠[/yellow] State secret [cyan]{secret_name}[/cyan] " - f"not found in namespace [cyan]{namespace}[/cyan]" + f" [yellow]⚠[/yellow] State secret [cyan]{secret_name}[/cyan] not found in namespace [cyan]{namespace}[/cyan]" ) return None - logger.error( - f" [bold red]✘[/bold red] Kubernetes API error while retrieving state " - f"(HTTP {exc.status}): {exc.reason}" - ) + logger.error(f" [bold red]✘[/bold red] Kubernetes API error while retrieving state (HTTP {exc.status}): {exc.reason}") sys.exit(1) except Exception as exc: - logger.error( - f" [bold red]✘[/bold red] Failed to connect to the Kubernetes cluster: {exc}" - ) + logger.error(f" [bold red]✘[/bold red] Failed to connect to the Kubernetes cluster: {exc}") sys.exit(1) if not secret.data or STATE_KEY not in secret.data: logger.warning( - f" [yellow]⚠[/yellow] State secret [cyan]{secret_name}[/cyan] exists " - f"but contains no [cyan]{STATE_KEY}[/cyan] key" + f" [yellow]⚠[/yellow] State secret [cyan]{secret_name}[/cyan] exists but contains no [cyan]{STATE_KEY}[/cyan] key" ) return None state = _decode(secret.data[STATE_KEY]) - logger.info( - f" [green]✔[/green] State loaded from secret [cyan]{secret_name}[/cyan] " - f"in namespace [cyan]{namespace}[/cyan]" - ) + logger.info(f" [green]✔[/green] State loaded from secret [cyan]{secret_name}[/cyan] in namespace [cyan]{namespace}[/cyan]") return state diff --git a/tests/e2e/test_e2e.sh b/tests/e2e/test_e2e.sh index 19e5fd46..ca993027 100755 --- a/tests/e2e/test_e2e.sh +++ b/tests/e2e/test_e2e.sh @@ -11,15 +11,14 @@ fi # Set testing namespace export CONTEXT="e2e" export TENANT="tenant-sphinx" -export STATE="teststate" -babylon namespace use -c ${CONTEXT} -t ${TENANT} -s $STATE +babylon namespace use -c ${CONTEXT} -t ${TENANT} babylon namespace get-states local babylon namespace get-contexts # Get version babylon api about -babylon init +babylon init azure babylon apply --exclude webapp project babylon destroy \ No newline at end of file diff --git a/tests/integration/test_api_endpoints.sh b/tests/integration/test_api_endpoints.sh index f5ec73d7..7e277d12 100755 --- a/tests/integration/test_api_endpoints.sh +++ b/tests/integration/test_api_endpoints.sh @@ -13,9 +13,8 @@ mkdir output # Set testing namespace export CONTEXT="integration" export TENANT="tenant-sphinx" -export STATE="teststate" -babylon namespace use -c ${CONTEXT} -t ${TENANT} -s $STATE +babylon namespace use -c ${CONTEXT} -t ${TENANT} babylon namespace get-states local babylon namespace get-contexts diff --git a/tests/unit/test_macro.py b/tests/unit/test_macro.py index 8714b1ce..8bb8955a 100644 --- a/tests/unit/test_macro.py +++ b/tests/unit/test_macro.py @@ -4,7 +4,7 @@ from cosmotech_api.models.solution_access_control import SolutionAccessControl from cosmotech_api.models.workspace_access_control import WorkspaceAccessControl -from Babylon.commands.macro.deploy import diff, resolve_inclusion_exclusion +from Babylon.commands.macro.helpers.common import diff, resolve_inclusion_exclusion def test_organization_diff(): diff --git a/uv.lock b/uv.lock index e4a65c8a..a53a3e78 100644 --- a/uv.lock +++ b/uv.lock @@ -169,7 +169,7 @@ requires-dist = [ { name = "azure-storage-blob", specifier = ">=12.28.0" }, { name = "click" }, { name = "click-log" }, - { name = "cosmotech-api", specifier = "==5.0.0" }, + { name = "cosmotech-api", specifier = "==5.0.1" }, { name = "cryptography" }, { name = "dynaconf" }, { name = "flatten-json" }, @@ -214,15 +214,15 @@ wheels = [ [[package]] name = "blessed" -version = "1.33.0" +version = "1.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinxed", marker = "sys_platform == 'win32'" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/5c/92dc10a25a4eafb4b9bef5dad522a0b7d5d5b55d2d76f9a6721b2e49ca2c/blessed-1.33.0.tar.gz", hash = "sha256:c732a1043042d84f411423a1a7b74643e1dd3a2271bd6e5955682dd4a321b0ef", size = 13980368, upload-time = "2026-03-07T00:00:06.288Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/1f/f2535d0eb1fb8af7915f96b4d42810345c255bbbca39939a23e59c0695d8/blessed-1.38.0.tar.gz", hash = "sha256:89ce6ec6567f7aced0716b73577b7a1702eb23c667838bb46d7d9bd48c36d1b3", size = 14008103, upload-time = "2026-03-30T22:47:36.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/04/2b4e111e0b902b1ac0b25e5e010af71c79fca093a3399bd7f8b82adcc536/blessed-1.33.0-py3-none-any.whl", hash = "sha256:1bc8ecac6d139286ea51ec1683433528ce75b0c60db77b7d881112bf9fc85b0f", size = 111519, upload-time = "2026-03-07T00:00:00.202Z" }, + { url = "https://files.pythonhosted.org/packages/08/81/26113a258b8b4068a7ae528cb1c13f1af2acfa0702368312183013ddc4f4/blessed-1.38.0-py3-none-any.whl", hash = "sha256:905884ae650e41284fa4fd7d0c3eed5e5b4a42be8c2bfb24c90d79fbf26a1490", size = 121251, upload-time = "2026-03-30T22:47:34.138Z" }, ] [[package]] @@ -293,87 +293,87 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -399,7 +399,7 @@ wheels = [ [[package]] name = "cosmotech-api" -version = "5.0.0" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -407,62 +407,62 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/07/4cfc51935a2cf9278f6f51d1fb59b402c26895dafc5923fdebe99f77afcb/cosmotech_api-5.0.0.tar.gz", hash = "sha256:182b6801a4a5008feeb7e20b367c5420005421978ef79e69edeacf7495456d5a", size = 219103, upload-time = "2026-02-23T15:50:44.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/fe/63d70c38d0c6b1a0319bf0b967883c4ff1ca3146e1c9933d3986eb84eb4f/cosmotech_api-5.0.1.tar.gz", hash = "sha256:f399350288e8708074bdd65e32e35adee139ef00dd3daf71789b7a54453f524a", size = 218883, upload-time = "2026-03-26T11:22:47.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/7e/1294914773dd6407395d22ae9734a68455443d55e526ceaff3d9a08b0b3c/cosmotech_api-5.0.0-py3-none-any.whl", hash = "sha256:1e41ea2c9ab09ea678e1861e5751c6976430989f51f34496dce4b8e1772c0b0f", size = 625091, upload-time = "2026-02-23T15:50:42.546Z" }, + { url = "https://files.pythonhosted.org/packages/05/0c/a2170429bcc84061422c8c2f95d839312cbaaaebfd902af15b959945c15c/cosmotech_api-5.0.1-py3-none-any.whl", hash = "sha256:91338003e8ee7fe4444d24d8af1c8601b715ebb6e1dfe258758d34cf718350b7", size = 624861, upload-time = "2026-03-26T11:22:46.079Z" }, ] [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, ] [[package]] @@ -522,11 +522,11 @@ wheels = [ [[package]] name = "griffelib" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/d7/2b805e89cdc609e5b304361d80586b272ef00f6287ee63de1e571b1f71ec/griffelib-2.0.1.tar.gz", hash = "sha256:59f39eabb4c777483a3823e39e8f9e03e69df271a7e49aee64e91a8cfa91bdf5", size = 166383, upload-time = "2026-03-23T21:05:25.882Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/4c/cc8c68196db727cfc1432f2ad5de50aa6707e630d44b2e6361dc06d8f134/griffelib-2.0.1-py3-none-any.whl", hash = "sha256:b769eed581c0e857d362fc8fcd8e57ecd2330c124b6104ac8b4c1c86d76970aa", size = 142377, upload-time = "2026-03-23T21:04:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] @@ -584,14 +584,14 @@ wheels = [ [[package]] name = "jinxed" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ansicon", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/e9/96633f12b6829eb1e91e70e5846704c0b1293ec47bd65a7b681e19c8eeff/jinxed-1.4.0.tar.gz", hash = "sha256:8f7801a10799de39e509eb5abc6d131ee169c1ce4fd5d568aa85b5f56ed58068", size = 37169, upload-time = "2026-03-26T01:49:38.337Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, + { url = "https://files.pythonhosted.org/packages/71/b7/9ab2b79bcbcc53cf8772a19d26713dd9574d4d81ee4fea29678d8cadcec7/jinxed-1.4.0-py2.py3-none-any.whl", hash = "sha256:95876a8b270081b8e28a9bbcbabe4fa98327faa91102526f724ed1904f9a55ac", size = 34522, upload-time = "2026-03-26T01:49:36.762Z" }, ] [[package]] @@ -1099,11 +1099,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -1122,15 +1122,15 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "10.21" +version = "10.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, ] [[package]] @@ -1230,16 +1230,16 @@ wheels = [ [[package]] name = "readchar" -version = "4.2.1" +version = "4.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/f8/8657b8cbb4ebeabfbdf991ac40eca8a1d1bd012011bd44ad1ed10f5cb494/readchar-4.2.1.tar.gz", hash = "sha256:91ce3faf07688de14d800592951e5575e9c7a3213738ed01d394dcc949b79adb", size = 9685, upload-time = "2024-11-04T18:28:07.757Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/49/a10341024c45bed95d13197ec9ef0f4e2fd10b5ca6e7f8d7684d18082398/readchar-4.2.2.tar.gz", hash = "sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8", size = 9762, upload-time = "2026-04-06T19:45:54.226Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/10/e4b1e0e5b6b6745c8098c275b69bc9d73e9542d5c7da4f137542b499ed44/readchar-4.2.1-py3-none-any.whl", hash = "sha256:a769305cd3994bb5fa2764aa4073452dc105a4ec39068ffe6efd3c20c60acc77", size = 9350, upload-time = "2024-11-04T18:28:02.859Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ca/36133653e00939922dd1416f4c56177361289172a30563fcb9552c9ccde4/readchar-4.2.2-py3-none-any.whl", hash = "sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200", size = 9401, upload-time = "2026-04-06T19:45:52.993Z" }, ] [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1247,9 +1247,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -1292,27 +1292,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] From e2648fee82f511bb55870aad368baf3ca7a20826 Mon Sep 17 00:00:00 2001 From: Mohcine Tor Date: Tue, 7 Apr 2026 14:35:10 +0200 Subject: [PATCH 8/8] fix copilot review --- Babylon/commands/macro/helpers/workspace.py | 7 ++++--- Babylon/commands/macro/init.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Babylon/commands/macro/helpers/workspace.py b/Babylon/commands/macro/helpers/workspace.py index 7bde7044..61e29fbf 100644 --- a/Babylon/commands/macro/helpers/workspace.py +++ b/Babylon/commands/macro/helpers/workspace.py @@ -159,8 +159,8 @@ def create_workspace_secret( v1.create_namespaced_secret(namespace=namespace, body=secret) logger.info(f" [bold green]✔[/bold green] Secret [magenta]{secret_name}[/magenta] created") return True - except client.ApiException as e: - if e.status == 409: + except client.exceptions.ApiException as e: + if getattr(e, "status", None) == 409: logger.warning(f" [yellow]⚠[/yellow] [dim]Secret [magenta]{secret_name}[/magenta] already exists[/dim]") return True logger.error(f" [bold red]✘[/bold red] Failed to create secret {secret_name}: {e.reason}") @@ -297,7 +297,8 @@ def get_postgres_service_host(namespace: str) -> str: services = v1.list_namespaced_service(namespace) for svc in services.items: - if "postgresql" in svc.metadata.name or svc.metadata.labels.get("app.kubernetes.io/name") == "postgresql": + labels = svc.metadata.labels or {} + if "postgresql" in svc.metadata.name or labels.get("app.kubernetes.io/name") == "postgresql": logger.info(f" [dim]→ Found PostgreSQL service {svc.metadata.name}[/dim]") return f"{svc.metadata.name}.{namespace}.svc.cluster.local" diff --git a/Babylon/commands/macro/init.py b/Babylon/commands/macro/init.py index e082057c..5a8b3fea 100644 --- a/Babylon/commands/macro/init.py +++ b/Babylon/commands/macro/init.py @@ -153,14 +153,14 @@ def _print_success_summary(project_path: Path, variables_file: str) -> None: @command() @option("--project-folder", default="project", help="Name of the project folder to create (default: 'project').") @option("--variables-file", default="variables.yaml", help="Name of the variables file (default: 'variables.yaml').") -@argument("cloud_provider", type=Choice(["aws", "azure", "gcp", "kob"], case_sensitive=False)) +@argument("cloud_provider", type=Choice(["azure", "kob"], case_sensitive=False)) def init(project_folder: str, variables_file: str, cloud_provider: str): """ Scaffolds a new Babylon project structure using YAML templates. arguments: - cloud_provider: Target cloud provider for webapp deployment (e.g., 'aws', 'azure', 'gcp', 'kob'). + cloud_provider: Target cloud provider for webapp deployment (e.g. 'azure', 'kob'). """ cwd = Path(getcwd()) project_path = cwd / project_folder