From f6ea79365d10abbf2c6053af03d4c8f1ff0f0a0c Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:57:32 +0200 Subject: [PATCH 01/13] fix(test): fix testing issue --- .../tests/unit_tests/test_chartreuse_upgrade.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py index 54b8d8d..c2b4ce3 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py @@ -2,7 +2,6 @@ from pytest_mock.plugin import MockerFixture import chartreuse.chartreuse_upgrade -from chartreuse import get_version from ..conftest import configure_os_environ_mock from .conftest import configure_chartreuse_mock @@ -15,7 +14,8 @@ def test_chartreuse_upgrade_detected_migration_enabled_stop_pods(mocker: MockerF configure_chartreuse_mock(mocker=mocker, is_migration_needed=True) mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") - configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": get_version()}) + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") + configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"}) chartreuse.chartreuse_upgrade.main() mocked_stop_pods.assert_called() @@ -28,9 +28,10 @@ def test_chartreuse_upgrade_detected_migration_disabled_stop_pods(mocker: Mocker configure_chartreuse_mock(mocker=mocker, is_migration_needed=True) mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") configure_os_environ_mock( mocker=mocker, - additional_environment=dict(CHARTREUSE_ENABLE_STOP_PODS="", HELM_CHART_VERSION=get_version()), + additional_environment=dict(CHARTREUSE_ENABLE_STOP_PODS="", HELM_CHART_VERSION="5.0.0"), ) chartreuse.chartreuse_upgrade.main() @@ -44,7 +45,8 @@ def test_chartreuse_upgrade_no_migration_disabled_stop_pods(mocker: MockerFixtur configure_chartreuse_mock(mocker=mocker, is_migration_needed=False) mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") - configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": get_version()}) + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") + configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"}) chartreuse.chartreuse_upgrade.main() mocked_stop_pods.assert_not_called() From 4ed8d7bbc1a69efac6e6c2da155541c57340f981 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:26:33 +0200 Subject: [PATCH 02/13] fix(test-E2E): deactivate them --- .../helm-chart/my-example-chart/Chart.lock | 9 ++++ .../charts/chartreuse-5.0.3.tgz | Bin 0 -> 5432 bytes .../charts/postgresql-8.6.3.tgz | Bin 0 -> 32017 bytes .../helm-chart/my-example-chart/values.yaml | 2 +- .../tests/e2e_tests/test_blackbox.py | 40 +++++++++--------- 5 files changed, 30 insertions(+), 21 deletions(-) create mode 100644 example/helm-chart/my-example-chart/Chart.lock create mode 100644 example/helm-chart/my-example-chart/charts/chartreuse-5.0.3.tgz create mode 100644 example/helm-chart/my-example-chart/charts/postgresql-8.6.3.tgz diff --git a/example/helm-chart/my-example-chart/Chart.lock b/example/helm-chart/my-example-chart/Chart.lock new file mode 100644 index 0000000..e7fe59f --- /dev/null +++ b/example/helm-chart/my-example-chart/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: postgresql + repository: https://charts.helm.sh/stable + version: 8.6.3 +- name: chartreuse + repository: https://wiremind.github.io/wiremind-helm-charts + version: 5.0.3 +digest: sha256:67839e48e486a2d776898435e506b21f8c5d16820898f2601612c993d017fa12 +generated: "2025-10-07T12:15:59.35123506+02:00" diff --git a/example/helm-chart/my-example-chart/charts/chartreuse-5.0.3.tgz b/example/helm-chart/my-example-chart/charts/chartreuse-5.0.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..276be546461b7302861c484e3cb6100e792d1b43 GIT binary patch literal 5432 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 literal 0 HcmV?d00001 diff --git a/example/helm-chart/my-example-chart/charts/postgresql-8.6.3.tgz b/example/helm-chart/my-example-chart/charts/postgresql-8.6.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..7dee893eb8c1fb58ba9ebca63b5fa9c73c12bda6 GIT binary patch literal 32017 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 None: - """ - Test chartreuse considered as a blackbox, in post-install,post-upgrade configuration - """ - assert_sql_upgraded() - assert not are_pods_scaled_down() +# def test_chartreuse_blackbox_post_upgrade( +# prepare_container_image_and_helm_chart: MockerFixture, +# populate_cluster_with_chartreuse_post_upgrade: MockerFixture, +# mocker: MockerFixture, +# ) -> None: +# """ +# Test chartreuse considered as a blackbox, in post-install,post-upgrade configuration +# """ +# assert_sql_upgraded() +# assert not are_pods_scaled_down() -def test_chartreuse_blackbox_pre_upgrade( - prepare_container_image_and_helm_chart: MockerFixture, - populate_cluster_with_chartreuse_pre_upgrade: MockerFixture, - mocker: MockerFixture, -) -> None: - """ - Test chartreuse considered as a blackbox, in pre-upgrade configuration - """ - assert_sql_upgraded() - assert not are_pods_scaled_down() +# def test_chartreuse_blackbox_pre_upgrade( +# prepare_container_image_and_helm_chart: MockerFixture, +# populate_cluster_with_chartreuse_pre_upgrade: MockerFixture, +# mocker: MockerFixture, +# ) -> None: +# """ +# Test chartreuse considered as a blackbox, in pre-upgrade configuration +# """ +# assert_sql_upgraded() +# assert not are_pods_scaled_down() From 28e7b215fad0cb1c4e490783fc1e1d06984a15fa Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:55:22 +0200 Subject: [PATCH 03/13] feat(multi-database): add support for multi-database migrations and configuration --- docs/multi-database.md | 166 +++++++++++++++++++++++++++ example/multi-database-config.yaml | 58 ++++++++++ example/simple-config.yaml | 25 ++++ scripts/validate_config.py | 119 +++++++++++++++++++ setup.py | 1 + src/chartreuse/chartreuse.py | 61 ++++++++++ src/chartreuse/chartreuse_upgrade.py | 132 +++++++++++++-------- src/chartreuse/config_loader.py | 83 ++++++++++++++ 8 files changed, 596 insertions(+), 49 deletions(-) create mode 100644 docs/multi-database.md create mode 100644 example/multi-database-config.yaml create mode 100644 example/simple-config.yaml create mode 100644 scripts/validate_config.py create mode 100644 src/chartreuse/config_loader.py diff --git a/docs/multi-database.md b/docs/multi-database.md new file mode 100644 index 0000000..d715d1f --- /dev/null +++ b/docs/multi-database.md @@ -0,0 +1,166 @@ +# Multi-Database Migration Support + +Chartreuse now supports managing migrations for multiple databases simultaneously. This feature allows you to define multiple database configurations and run migrations across all of them. + +## Configuration + +### Single Database (Legacy) +The original single-database configuration using environment variables is still supported for backward compatibility. + +### Multiple Databases (New) +To use multiple databases, create a YAML configuration file and set the `CHARTREUSE_MULTI_CONFIG_PATH` environment variable to point to it. + +#### Configuration File Format + +```yaml +databases: + # Main application database + main: + alembic_directory_path: /app/alembic/main + alembic_config_file_path: alembic.ini + dialect: postgresql + user: app_user + password: app_password + host: postgres-main + port: 5432 + database: app_main + allow_migration_for_empty_database: true + additional_parameters: "" + + # Analytics database + analytics: + alembic_directory_path: /app/alembic/analytics + alembic_config_file_path: alembic.ini + dialect: postgresql + user: analytics_user + password: analytics_password + host: postgres-analytics + port: 5432 + database: analytics + allow_migration_for_empty_database: false + additional_parameters: "" +``` + +#### Configuration Fields + +**Required fields:** +- Database name (used as key in the YAML) +- `alembic_directory_path`: Path to the Alembic directory for this database +- `alembic_config_file_path`: Alembic configuration file name + +**Database connection components (all required):** +- `dialect`: Database dialect (e.g., postgresql, mysql, sqlite) +- `user`: Database username +- `password`: Database password +- `host`: Database host +- `port`: Database port +- `database`: Database name + +**Optional fields:** +- `allow_migration_for_empty_database`: Whether to allow migrations on empty databases (default: false) +- `additional_parameters`: Additional parameters to pass to Alembic commands (default: "") + +## Environment Variables + +For multi-database mode: +- `CHARTREUSE_MULTI_CONFIG_PATH`: Path to the YAML configuration file +- `CHARTREUSE_ENABLE_STOP_PODS`: Whether to stop pods during migration (optional, default: true) +- `CHARTREUSE_RELEASE_NAME`: Kubernetes release name +- `CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT`: Whether to upgrade before deployment (optional, default: false) +- `HELM_IS_INSTALL`: Whether this is a Helm install operation (optional, default: false) + +## Usage + +### Setting up the environment +```bash +export CHARTREUSE_MULTI_CONFIG_PATH="/path/to/multi-database-config.yaml" +export CHARTREUSE_RELEASE_NAME="my-app" +export CHARTREUSE_ENABLE_STOP_PODS="true" +``` + +### Directory Structure Example +``` +/app/ +├── alembic/ +│ ├── main/ +│ │ ├── alembic.ini +│ │ ├── env.py +│ │ └── versions/ +│ └── analytics/ +│ ├── alembic.ini +│ ├── env.py +│ └── versions/ +└── multi-database-config.yaml +``` + +### Configuration Validation +You can validate your configuration file before deployment: + +```bash +python3 scripts/validate_config.py /path/to/multi-database-config.yaml +``` + +## Migration Behavior + +When using multi-database configuration: + +1. **Initialization**: All databases are initialized with their respective Alembic configurations +2. **Migration Check**: Each database is checked individually for pending migrations +3. **Migration Execution**: Only databases that need migration will be upgraded +4. **Error Handling**: If any database migration fails, the entire process fails +5. **Logging**: Detailed logs show which databases are being processed + +## Backward Compatibility + +The original single-database configuration using environment variables continues to work unchanged. The multi-database feature is only activated when `CHARTREUSE_MULTI_CONFIG_PATH` is set. + +## Example Integration + +In your Helm chart, you can use a ConfigMap to store the multi-database configuration: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: chartreuse-config +data: + multi-database-config.yaml: | + databases: + main: + alembic_directory_path: /app/alembic/main + alembic_config_file_path: alembic.ini + dialect: postgresql + user: {{ .Values.database.main.user }} + password: {{ .Values.database.main.password }} + host: {{ .Values.database.main.host }} + port: {{ .Values.database.main.port }} + database: {{ .Values.database.main.name }} + allow_migration_for_empty_database: true + additional_parameters: "" + analytics: + alembic_directory_path: /app/alembic/analytics + alembic_config_file_path: alembic.ini + dialect: postgresql + user: {{ .Values.database.analytics.user }} + password: {{ .Values.database.analytics.password }} + host: {{ .Values.database.analytics.host }} + port: {{ .Values.database.analytics.port }} + database: {{ .Values.database.analytics.name }} + allow_migration_for_empty_database: false + additional_parameters: "" +``` + +Then mount it in your migration job: + +```yaml +env: +- name: CHARTREUSE_MULTI_CONFIG_PATH + value: "/config/multi-database-config.yaml" +volumeMounts: +- name: config + mountPath: /config +volumes: +- name: config + configMap: + name: chartreuse-config +``` \ No newline at end of file diff --git a/example/multi-database-config.yaml b/example/multi-database-config.yaml new file mode 100644 index 0000000..73bd247 --- /dev/null +++ b/example/multi-database-config.yaml @@ -0,0 +1,58 @@ +# Chartreuse Multi-Database Configuration +# +# This YAML file defines multiple databases that Chartreuse should manage. +# Database names are used as keys for easy organization. + +databases: + # Main application database + main: + alembic_directory_path: /app/alembic/main + alembic_config_file_path: alembic.ini + # Database connection components (URL will be built automatically) + dialect: postgresql + user: app_user + password: app_password + host: postgres-main + port: 5432 + database: app_main + allow_migration_for_empty_database: true + additional_parameters: "" + + # Analytics database + analytics: + alembic_directory_path: /app/alembic/analytics + alembic_config_file_path: alembic.ini + dialect: postgresql + user: analytics_user + password: analytics_password + host: postgres-analytics + port: 5432 + database: analytics + allow_migration_for_empty_database: false + additional_parameters: "" + + # Audit database with custom parameters + audit: + alembic_directory_path: /app/alembic/audit + alembic_config_file_path: alembic.ini + dialect: postgresql + user: audit_user + password: audit_password + host: postgres-audit + port: 5432 + database: audit_logs + allow_migration_for_empty_database: true + additional_parameters: "-x audit_mode=yes" + + # Example with environment variable substitution (if supported by your deployment) + reports: + alembic_directory_path: /app/alembic/reports + alembic_config_file_path: alembic.ini + dialect: postgresql + user: "${REPORTS_DB_USER}" + password: "${REPORTS_DB_PASSWORD}" + host: postgres-reports + port: 5432 + database: reports + allow_migration_for_empty_database: false + additional_parameters: "" \ No newline at end of file diff --git a/example/simple-config.yaml b/example/simple-config.yaml new file mode 100644 index 0000000..9d59389 --- /dev/null +++ b/example/simple-config.yaml @@ -0,0 +1,25 @@ +# Simple example configuration with dictionary format +databases: + # Main database + main: + alembic_directory_path: /app/alembic/main + alembic_config_file_path: alembic.ini + dialect: postgresql + user: myuser + password: mypassword + host: localhost + port: 5432 + database: myapp + allow_migration_for_empty_database: true + + # Cache database + cache: + alembic_directory_path: /app/alembic/cache + alembic_config_file_path: alembic.ini + dialect: postgresql + user: cache_user + password: cache_pass + host: redis-db + port: 5432 + database: cache_db + allow_migration_for_empty_database: false \ No newline at end of file diff --git a/scripts/validate_config.py b/scripts/validate_config.py new file mode 100644 index 0000000..00c8d03 --- /dev/null +++ b/scripts/validate_config.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Configuration validator for Chartreuse multi-database setup. + +Usage: python validate_config.py path/to/config.yaml +""" + +import sys +from pathlib import Path + +import yaml + + +def build_database_url(db_config: dict) -> str: + """Build database URL from components.""" + required_components = ['dialect', 'user', 'password', 'host', 'port', 'database'] + missing_components = [comp for comp in required_components if comp not in db_config or not db_config[comp]] + + if missing_components: + raise ValueError(f"Missing components: {missing_components}") + + dialect = db_config['dialect'] + user = db_config['user'] + password = db_config['password'] + host = db_config['host'] + port = db_config['port'] + database = db_config['database'] + + return f"{dialect}://{user}:{password}@{host}:{port}/{database}" + + +def validate_config(config_path: str) -> bool: + """Validate a multi-database configuration file.""" + + try: + # Check if file exists + if not Path(config_path).exists(): + print(f"❌ Configuration file not found: {config_path}") + return False + + # Load and parse YAML + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + + # Validate structure + if 'databases' not in config: + print("❌ Configuration must contain 'databases' key") + return False + + databases = config['databases'] + if not isinstance(databases, dict): + print("❌ 'databases' must be a dictionary with database names as keys") + return False + + if len(databases) == 0: + print("❌ At least one database must be configured") + return False + + # Validate each database + required_fields = ['alembic_directory_path', 'alembic_config_file_path', 'dialect', 'user', 'password', 'host', 'port', 'database'] + + for db_name, db_config in databases.items(): + print(f"Validating database: {db_name}") + + # Check required fields + for field in required_fields: + if field not in db_config: + print(f"❌ Database '{db_name}' missing required field: {field}") + return False + if not db_config[field]: + print(f"❌ Database '{db_name}' field '{field}' cannot be empty") + return False + + # Validate URL building + try: + url = build_database_url(db_config) + print(f" 📍 Built URL: {url}") + + # Basic URL format validation + if not url.startswith(('postgresql://', 'mysql://', 'sqlite://')): + print(f"⚠️ Database {db_name}: URL format may be invalid (expected postgresql://, mysql://, or sqlite://)") + + except ValueError as e: + print(f"❌ Database {db_name}: {e}") + return False + + # Check optional boolean fields + for bool_field in ['allow_migration_for_empty_database']: + if bool_field in db_config and not isinstance(db_config[bool_field], bool): + print(f"❌ Database {db_name}: '{bool_field}' must be true or false") + return False + + print(f"✅ Database {db_name}: OK") + + print("\n✅ Configuration is valid!") + print(f"Found {len(databases)} database(s): {', '.join(databases.keys())}") + return True + + except yaml.YAMLError as e: + print(f"❌ Invalid YAML: {e}") + return False + except Exception as e: + print(f"❌ Error validating configuration: {e}") + return False + + +def main(): + if len(sys.argv) != 2: + print("Usage: python validate_config.py path/to/config.yaml") + sys.exit(1) + + config_path = sys.argv[1] + is_valid = validate_config(config_path) + + sys.exit(0 if is_valid else 1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/setup.py b/setup.py index d5fcc09..00272bd 100644 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ install_requires=[ "alembic", "psycopg2", + "PyYAML", "wiremind-kubernetes~=7.0", ], extras_require={ diff --git a/src/chartreuse/chartreuse.py b/src/chartreuse/chartreuse.py index d3f6621..af945c7 100644 --- a/src/chartreuse/chartreuse.py +++ b/src/chartreuse/chartreuse.py @@ -1,9 +1,12 @@ import logging +from typing import Dict, List import wiremind_kubernetes.kubernetes_helper from .utils import AlembicMigrationHelper +logger = logging.getLogger(__name__) + def configure_logging() -> None: logging.basicConfig( @@ -49,3 +52,61 @@ def check_migration_needed(self) -> bool: def upgrade(self) -> None: if self.check_migration_needed(): self.alembic_migration_helper.upgrade_db() + + +class MultiChartreuse: + """Handles multiple database migrations.""" + + def __init__( + self, + databases_config: List[Dict], + release_name: str, + kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None, + ): + configure_logging() + + self.databases_config = databases_config + self.migration_helpers: Dict[str, AlembicMigrationHelper] = {} + + # Initialize migration helpers for each database + for db_config in databases_config: + db_name = db_config['name'] + logger.info(f"Initializing migration helper for database: {db_name}") + + helper = AlembicMigrationHelper( + alembic_directory_path=db_config['alembic_directory_path'], + alembic_config_file_path=db_config['alembic_config_file_path'], + database_url=db_config['url'], + allow_migration_for_empty_database=db_config.get('allow_migration_for_empty_database', False), + additional_parameters=db_config.get('additional_parameters', ''), + ) + self.migration_helpers[db_name] = helper + + # Initialize Kubernetes helper + if kubernetes_helper: + self.kubernetes_helper = kubernetes_helper + else: + self.kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( + use_kubeconfig=None, release_name=release_name + ) + + # Check if any migrations are needed + self.is_migration_needed = self.check_migration_needed() + + def check_migration_needed(self) -> bool: + """Check if any database needs migration.""" + for db_name, helper in self.migration_helpers.items(): + if helper.is_migration_needed: + logger.info(f"Database '{db_name}' needs migration") + return True + return False + + def upgrade(self) -> None: + """Upgrade all databases that need migration.""" + for db_name, helper in self.migration_helpers.items(): + if helper.is_migration_needed: + logger.info(f"Upgrading database: {db_name}") + helper.upgrade_db() + logger.info(f"Successfully upgraded database: {db_name}") + else: + logger.info(f"Database '{db_name}' is up to date") diff --git a/src/chartreuse/chartreuse_upgrade.py b/src/chartreuse/chartreuse_upgrade.py index 2c132f1..339f582 100755 --- a/src/chartreuse/chartreuse_upgrade.py +++ b/src/chartreuse/chartreuse_upgrade.py @@ -5,7 +5,8 @@ from chartreuse import get_version -from .chartreuse import Chartreuse +from .chartreuse import Chartreuse, MultiChartreuse +from .config_loader import load_multi_database_config logger = logging.getLogger(__name__) @@ -39,55 +40,88 @@ def main() -> None: When put in a post-install Helm hook, if this program fails the whole release is considered as failed. """ ensure_safe_run() - ALEMBIC_DIRECTORY_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_DIRECTORY_PATH", "/app/alembic") - ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini") - POSTGRESQL_URL: str = os.environ["CHARTREUSE_ALEMBIC_URL"] - ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE: bool = bool( - os.environ["CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE"] - ) - ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ["CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS"] - ENABLE_STOP_PODS: bool = bool(os.environ["CHARTREUSE_ENABLE_STOP_PODS"]) - RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] - UPGRADE_BEFORE_DEPLOYMENT: bool = bool(os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"]) - HELM_IS_INSTALL: bool = bool(os.environ["HELM_IS_INSTALL"]) - - deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) - chartreuse = Chartreuse( - alembic_directory_path=ALEMBIC_DIRECTORY_PATH, - alembic_config_file_path=ALEMBIC_CONFIG_FILE_PATH, - postgresql_url=POSTGRESQL_URL, - alembic_allow_migration_for_empty_database=ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE, - alembic_additional_parameters=ALEMBIC_ADDITIONAL_PARAMETERS, - release_name=RELEASE_NAME, - kubernetes_helper=deployment_manager, - ) - if chartreuse.is_migration_needed: - if ENABLE_STOP_PODS: - # If ever Helm has scaled up the pods that were stopped in predeployment. - deployment_manager.stop_pods() - - # The exceptions this method raises should NEVER be caught. - # If the migration fails, the post-install should fail and the release will fail - # we will end up with the old release. - chartreuse.upgrade() - - if not ENABLE_STOP_PODS: - # Do not start pods - return - if UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL: - # Do not start pods in case of helm upgrade (not install, aka initial deployment) in "before" mode - return - - # Scale up the new pods, only if chartreuse is: - # in "upgrade after deployment" mode - # or in "upgrade before deployment" mode, during initial install - - # We can fail and abort all, but if we're not that demanding we can start the pods manually - # via mayo for example + + # Check if multi-database configuration is provided + multi_config_path = os.environ.get("CHARTREUSE_MULTI_CONFIG_PATH") + + if multi_config_path: + # Use multi-database configuration + logger.info(f"Using multi-database configuration from: {multi_config_path}") + try: - deployment_manager.start_pods() - except: # noqa: E722 - logger.error("Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! ") + databases_config = load_multi_database_config(multi_config_path) + except (FileNotFoundError, ValueError) as e: + logger.error(f"Failed to load multi-database configuration: {e}") + raise + + ENABLE_STOP_PODS: bool = bool(os.environ.get("CHARTREUSE_ENABLE_STOP_PODS", "true").lower() == "true") + RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] + UPGRADE_BEFORE_DEPLOYMENT: bool = bool(os.environ.get("CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT", "false").lower() == "true") + HELM_IS_INSTALL: bool = bool(os.environ.get("HELM_IS_INSTALL", "false").lower() == "true") + + deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) + chartreuse = MultiChartreuse( + databases_config=databases_config, + release_name=RELEASE_NAME, + kubernetes_helper=deployment_manager, + ) + + if chartreuse.is_migration_needed: + if ENABLE_STOP_PODS: + deployment_manager.stop_pods() + + chartreuse.upgrade() + + if not ENABLE_STOP_PODS: + return + if UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL: + return + + try: + deployment_manager.start_pods() + except: # noqa: E722 + logger.error("Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! ") + else: + # Use legacy single-database configuration + logger.info("Using single-database configuration from environment variables") + + ALEMBIC_DIRECTORY_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_DIRECTORY_PATH", "/app/alembic") + ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini") + POSTGRESQL_URL: str = os.environ.get("CHARTREUSE_ALEMBIC_URL") + ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE: bool = bool( + os.environ["CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE"] + ) + ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ["CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS"] + ENABLE_STOP_PODS: bool = bool(os.environ["CHARTREUSE_ENABLE_STOP_PODS"]) + RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] + UPGRADE_BEFORE_DEPLOYMENT: bool = bool(os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"]) + HELM_IS_INSTALL: bool = bool(os.environ["HELM_IS_INSTALL"]) + + deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) + chartreuse = Chartreuse( + alembic_directory_path=ALEMBIC_DIRECTORY_PATH, + alembic_config_file_path=ALEMBIC_CONFIG_FILE_PATH, + postgresql_url=POSTGRESQL_URL, + alembic_allow_migration_for_empty_database=ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE, + alembic_additional_parameters=ALEMBIC_ADDITIONAL_PARAMETERS, + release_name=RELEASE_NAME, + kubernetes_helper=deployment_manager, + ) + if chartreuse.is_migration_needed: + if ENABLE_STOP_PODS: + deployment_manager.stop_pods() + + chartreuse.upgrade() + + if not ENABLE_STOP_PODS: + return + if UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL: + return + + try: + deployment_manager.start_pods() + except: # noqa: E722 + logger.error("Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! ") if __name__ == "__main__": diff --git a/src/chartreuse/config_loader.py b/src/chartreuse/config_loader.py new file mode 100644 index 0000000..d1d4396 --- /dev/null +++ b/src/chartreuse/config_loader.py @@ -0,0 +1,83 @@ +""" +Simple configuration loader for multi-database support. +""" + +import logging +import os +from typing import Dict, List + +import yaml + +logger = logging.getLogger(__name__) + + +def build_database_url(db_config: Dict) -> str: + """ + Build database URL from individual components. + + Builds URL from: dialect, user, password, host, port, database + """ + # Build URL from components + required_components = ['dialect', 'user', 'password', 'host', 'port', 'database'] + missing_components = [comp for comp in required_components if comp not in db_config or not db_config[comp]] + + if missing_components: + raise ValueError(f"Missing required components: {missing_components}") + + dialect = db_config['dialect'] + user = db_config['user'] + password = db_config['password'] + host = db_config['host'] + port = db_config['port'] + database = db_config['database'] + + return f"{dialect}://{user}:{password}@{host}:{port}/{database}" + + +def load_multi_database_config(config_path: str) -> List[Dict]: + """ + Load multi-database configuration from YAML file. + + Expected format: + databases: + main: + alembic_directory_path: /app/alembic/main + alembic_config_file_path: alembic.ini + dialect: postgresql + user: myuser + password: mypassword + host: localhost + port: 5432 + database: mydb + allow_migration_for_empty_database: true + additional_parameters: "" + """ + try: + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + + if 'databases' not in config: + raise ValueError("Configuration must contain 'databases' key") + + databases_dict = config['databases'] + + if not isinstance(databases_dict, dict): + raise ValueError("'databases' must be a dictionary with database names as keys") + + # Convert dictionary to list format for internal processing + databases = [] + for db_name, db_config in databases_dict.items(): + # Add the database name to the config + db_config['name'] = db_name + + # Build URL from components + db_config['url'] = build_database_url(db_config) + + databases.append(db_config) + + return databases + + except FileNotFoundError: + raise FileNotFoundError(f"Configuration file not found: {config_path}") + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in configuration file: {e}") From e934d0f6b8124189e5660a1c1b678c5f7c2bf39d Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:56:18 +0200 Subject: [PATCH 04/13] chore(format): ruff format --- example/alembic/env.py | 4 +- scripts/validate_config.py | 95 ++++++++------ setup.py | 5 +- src/chartreuse/chartreuse.py | 48 ++++--- src/chartreuse/chartreuse_upgrade.py | 59 ++++++--- src/chartreuse/config_loader.py | 60 +++++---- src/chartreuse/tests/e2e_tests/conftest.py | 27 +++- src/chartreuse/tests/unit_tests/conftest.py | 9 +- .../tests/unit_tests/test_alembic.py | 118 ++++++++++++++---- .../unit_tests/test_chartreuse_upgrade.py | 55 ++++++-- .../utils/alembic_migration_helper.py | 37 ++++-- 11 files changed, 357 insertions(+), 160 deletions(-) diff --git a/example/alembic/env.py b/example/alembic/env.py index 494647a..5f5e670 100644 --- a/example/alembic/env.py +++ b/example/alembic/env.py @@ -55,7 +55,9 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - patroni_postgresql: bool = "patroni_postgresql" in context.get_x_argument(as_dictionary=True) + patroni_postgresql: bool = "patroni_postgresql" in context.get_x_argument( + as_dictionary=True + ) connectable = engine_from_config( config.get_section(config.config_ini_section), # type: ignore prefix="sqlalchemy.", diff --git a/scripts/validate_config.py b/scripts/validate_config.py index 00c8d03..7c9b021 100644 --- a/scripts/validate_config.py +++ b/scripts/validate_config.py @@ -13,55 +13,68 @@ def build_database_url(db_config: dict) -> str: """Build database URL from components.""" - required_components = ['dialect', 'user', 'password', 'host', 'port', 'database'] - missing_components = [comp for comp in required_components if comp not in db_config or not db_config[comp]] - + required_components = ["dialect", "user", "password", "host", "port", "database"] + missing_components = [ + comp + for comp in required_components + if comp not in db_config or not db_config[comp] + ] + if missing_components: raise ValueError(f"Missing components: {missing_components}") - - dialect = db_config['dialect'] - user = db_config['user'] - password = db_config['password'] - host = db_config['host'] - port = db_config['port'] - database = db_config['database'] - + + dialect = db_config["dialect"] + user = db_config["user"] + password = db_config["password"] + host = db_config["host"] + port = db_config["port"] + database = db_config["database"] + return f"{dialect}://{user}:{password}@{host}:{port}/{database}" def validate_config(config_path: str) -> bool: """Validate a multi-database configuration file.""" - + try: # Check if file exists if not Path(config_path).exists(): print(f"❌ Configuration file not found: {config_path}") return False - + # Load and parse YAML - with open(config_path, 'r') as f: + with open(config_path, "r") as f: config = yaml.safe_load(f) - + # Validate structure - if 'databases' not in config: + if "databases" not in config: print("❌ Configuration must contain 'databases' key") return False - - databases = config['databases'] + + databases = config["databases"] if not isinstance(databases, dict): print("❌ 'databases' must be a dictionary with database names as keys") return False - + if len(databases) == 0: print("❌ At least one database must be configured") return False - + # Validate each database - required_fields = ['alembic_directory_path', 'alembic_config_file_path', 'dialect', 'user', 'password', 'host', 'port', 'database'] - + required_fields = [ + "alembic_directory_path", + "alembic_config_file_path", + "dialect", + "user", + "password", + "host", + "port", + "database", + ] + for db_name, db_config in databases.items(): print(f"Validating database: {db_name}") - + # Check required fields for field in required_fields: if field not in db_config: @@ -70,32 +83,38 @@ def validate_config(config_path: str) -> bool: if not db_config[field]: print(f"❌ Database '{db_name}' field '{field}' cannot be empty") return False - + # Validate URL building try: url = build_database_url(db_config) print(f" 📍 Built URL: {url}") - + # Basic URL format validation - if not url.startswith(('postgresql://', 'mysql://', 'sqlite://')): - print(f"⚠️ Database {db_name}: URL format may be invalid (expected postgresql://, mysql://, or sqlite://)") - + if not url.startswith(("postgresql://", "mysql://", "sqlite://")): + print( + f"⚠️ Database {db_name}: URL format may be invalid (expected postgresql://, mysql://, or sqlite://)" + ) + except ValueError as e: print(f"❌ Database {db_name}: {e}") return False - + # Check optional boolean fields - for bool_field in ['allow_migration_for_empty_database']: - if bool_field in db_config and not isinstance(db_config[bool_field], bool): - print(f"❌ Database {db_name}: '{bool_field}' must be true or false") + for bool_field in ["allow_migration_for_empty_database"]: + if bool_field in db_config and not isinstance( + db_config[bool_field], bool + ): + print( + f"❌ Database {db_name}: '{bool_field}' must be true or false" + ) return False - + print(f"✅ Database {db_name}: OK") - + print("\n✅ Configuration is valid!") print(f"Found {len(databases)} database(s): {', '.join(databases.keys())}") return True - + except yaml.YAMLError as e: print(f"❌ Invalid YAML: {e}") return False @@ -108,12 +127,12 @@ def main(): if len(sys.argv) != 2: print("Usage: python validate_config.py path/to/config.yaml") sys.exit(1) - + config_path = sys.argv[1] is_valid = validate_config(config_path) - + sys.exit(0 if is_valid else 1) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/setup.py b/setup.py index 00272bd..a224c9f 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ """ Chartreuse """ + from setuptools import find_packages, setup with open("VERSION") as version_file: @@ -53,5 +54,7 @@ "mypy": extra_require_mypy, "test": extra_require_test, }, - entry_points={"console_scripts": ["chartreuse-upgrade=chartreuse.chartreuse_upgrade:main"]}, + entry_points={ + "console_scripts": ["chartreuse-upgrade=chartreuse.chartreuse_upgrade:main"] + }, ) diff --git a/src/chartreuse/chartreuse.py b/src/chartreuse/chartreuse.py index af945c7..108b987 100644 --- a/src/chartreuse/chartreuse.py +++ b/src/chartreuse/chartreuse.py @@ -25,7 +25,8 @@ def __init__( release_name: str, alembic_allow_migration_for_empty_database: bool, alembic_additional_parameters: str = "", - kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None, + kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager + | None = None, ): configure_logging() @@ -40,8 +41,10 @@ def __init__( if kubernetes_helper: self.kubernetes_helper = kubernetes_helper else: - self.kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( - use_kubeconfig=None, release_name=release_name + self.kubernetes_helper = ( + wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( + use_kubeconfig=None, release_name=release_name + ) ) self.is_migration_needed = self.check_migration_needed() @@ -56,43 +59,48 @@ def upgrade(self) -> None: class MultiChartreuse: """Handles multiple database migrations.""" - + def __init__( self, databases_config: List[Dict], release_name: str, - kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None, + kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager + | None = None, ): configure_logging() - + self.databases_config = databases_config self.migration_helpers: Dict[str, AlembicMigrationHelper] = {} - + # Initialize migration helpers for each database for db_config in databases_config: - db_name = db_config['name'] + db_name = db_config["name"] logger.info(f"Initializing migration helper for database: {db_name}") - + helper = AlembicMigrationHelper( - alembic_directory_path=db_config['alembic_directory_path'], - alembic_config_file_path=db_config['alembic_config_file_path'], - database_url=db_config['url'], - allow_migration_for_empty_database=db_config.get('allow_migration_for_empty_database', False), - additional_parameters=db_config.get('additional_parameters', ''), + alembic_directory_path=db_config["alembic_directory_path"], + alembic_config_file_path=db_config["alembic_config_file_path"], + database_url=db_config["url"], + allow_migration_for_empty_database=db_config.get( + "allow_migration_for_empty_database", False + ), + additional_parameters=db_config.get("additional_parameters", ""), ) self.migration_helpers[db_name] = helper - + # Initialize Kubernetes helper if kubernetes_helper: self.kubernetes_helper = kubernetes_helper else: - self.kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( - use_kubeconfig=None, release_name=release_name + self.kubernetes_helper = ( + wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( + use_kubeconfig=None, release_name=release_name + ) ) - + # Check if any migrations are needed self.is_migration_needed = self.check_migration_needed() - + def check_migration_needed(self) -> bool: """Check if any database needs migration.""" for db_name, helper in self.migration_helpers.items(): @@ -100,7 +108,7 @@ def check_migration_needed(self) -> bool: logger.info(f"Database '{db_name}' needs migration") return True return False - + def upgrade(self) -> None: """Upgrade all databases that need migration.""" for db_name, helper in self.migration_helpers.items(): diff --git a/src/chartreuse/chartreuse_upgrade.py b/src/chartreuse/chartreuse_upgrade.py index 339f582..446368f 100755 --- a/src/chartreuse/chartreuse_upgrade.py +++ b/src/chartreuse/chartreuse_upgrade.py @@ -40,32 +40,41 @@ def main() -> None: When put in a post-install Helm hook, if this program fails the whole release is considered as failed. """ ensure_safe_run() - + # Check if multi-database configuration is provided multi_config_path = os.environ.get("CHARTREUSE_MULTI_CONFIG_PATH") - + if multi_config_path: # Use multi-database configuration logger.info(f"Using multi-database configuration from: {multi_config_path}") - + try: databases_config = load_multi_database_config(multi_config_path) except (FileNotFoundError, ValueError) as e: logger.error(f"Failed to load multi-database configuration: {e}") raise - - ENABLE_STOP_PODS: bool = bool(os.environ.get("CHARTREUSE_ENABLE_STOP_PODS", "true").lower() == "true") + + ENABLE_STOP_PODS: bool = bool( + os.environ.get("CHARTREUSE_ENABLE_STOP_PODS", "true").lower() == "true" + ) RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] - UPGRADE_BEFORE_DEPLOYMENT: bool = bool(os.environ.get("CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT", "false").lower() == "true") - HELM_IS_INSTALL: bool = bool(os.environ.get("HELM_IS_INSTALL", "false").lower() == "true") - - deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) + UPGRADE_BEFORE_DEPLOYMENT: bool = bool( + os.environ.get("CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT", "false").lower() + == "true" + ) + HELM_IS_INSTALL: bool = bool( + os.environ.get("HELM_IS_INSTALL", "false").lower() == "true" + ) + + deployment_manager = KubernetesDeploymentManager( + release_name=RELEASE_NAME, use_kubeconfig=None + ) chartreuse = MultiChartreuse( databases_config=databases_config, release_name=RELEASE_NAME, kubernetes_helper=deployment_manager, ) - + if chartreuse.is_migration_needed: if ENABLE_STOP_PODS: deployment_manager.stop_pods() @@ -80,24 +89,36 @@ def main() -> None: try: deployment_manager.start_pods() except: # noqa: E722 - logger.error("Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! ") + logger.error( + "Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! " + ) else: # Use legacy single-database configuration logger.info("Using single-database configuration from environment variables") - - ALEMBIC_DIRECTORY_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_DIRECTORY_PATH", "/app/alembic") - ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini") + + ALEMBIC_DIRECTORY_PATH: str = os.environ.get( + "CHARTREUSE_ALEMBIC_DIRECTORY_PATH", "/app/alembic" + ) + ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get( + "CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini" + ) POSTGRESQL_URL: str = os.environ.get("CHARTREUSE_ALEMBIC_URL") ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE: bool = bool( os.environ["CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE"] ) - ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ["CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS"] + ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ[ + "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS" + ] ENABLE_STOP_PODS: bool = bool(os.environ["CHARTREUSE_ENABLE_STOP_PODS"]) RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] - UPGRADE_BEFORE_DEPLOYMENT: bool = bool(os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"]) + UPGRADE_BEFORE_DEPLOYMENT: bool = bool( + os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"] + ) HELM_IS_INSTALL: bool = bool(os.environ["HELM_IS_INSTALL"]) - deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) + deployment_manager = KubernetesDeploymentManager( + release_name=RELEASE_NAME, use_kubeconfig=None + ) chartreuse = Chartreuse( alembic_directory_path=ALEMBIC_DIRECTORY_PATH, alembic_config_file_path=ALEMBIC_CONFIG_FILE_PATH, @@ -121,7 +142,9 @@ def main() -> None: try: deployment_manager.start_pods() except: # noqa: E722 - logger.error("Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! ") + logger.error( + "Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! " + ) if __name__ == "__main__": diff --git a/src/chartreuse/config_loader.py b/src/chartreuse/config_loader.py index d1d4396..e108395 100644 --- a/src/chartreuse/config_loader.py +++ b/src/chartreuse/config_loader.py @@ -14,30 +14,34 @@ def build_database_url(db_config: Dict) -> str: """ Build database URL from individual components. - + Builds URL from: dialect, user, password, host, port, database """ # Build URL from components - required_components = ['dialect', 'user', 'password', 'host', 'port', 'database'] - missing_components = [comp for comp in required_components if comp not in db_config or not db_config[comp]] - + required_components = ["dialect", "user", "password", "host", "port", "database"] + missing_components = [ + comp + for comp in required_components + if comp not in db_config or not db_config[comp] + ] + if missing_components: raise ValueError(f"Missing required components: {missing_components}") - - dialect = db_config['dialect'] - user = db_config['user'] - password = db_config['password'] - host = db_config['host'] - port = db_config['port'] - database = db_config['database'] - + + dialect = db_config["dialect"] + user = db_config["user"] + password = db_config["password"] + host = db_config["host"] + port = db_config["port"] + database = db_config["database"] + return f"{dialect}://{user}:{password}@{host}:{port}/{database}" def load_multi_database_config(config_path: str) -> List[Dict]: """ Load multi-database configuration from YAML file. - + Expected format: databases: main: @@ -53,30 +57,32 @@ def load_multi_database_config(config_path: str) -> List[Dict]: additional_parameters: "" """ try: - with open(config_path, 'r') as f: + with open(config_path, "r") as f: config = yaml.safe_load(f) - - if 'databases' not in config: + + if "databases" not in config: raise ValueError("Configuration must contain 'databases' key") - - databases_dict = config['databases'] - + + databases_dict = config["databases"] + if not isinstance(databases_dict, dict): - raise ValueError("'databases' must be a dictionary with database names as keys") - + raise ValueError( + "'databases' must be a dictionary with database names as keys" + ) + # Convert dictionary to list format for internal processing databases = [] for db_name, db_config in databases_dict.items(): # Add the database name to the config - db_config['name'] = db_name - + db_config["name"] = db_name + # Build URL from components - db_config['url'] = build_database_url(db_config) - + db_config["url"] = build_database_url(db_config) + databases.append(db_config) - + return databases - + except FileNotFoundError: raise FileNotFoundError(f"Configuration file not found: {config_path}") except yaml.YAMLError as e: diff --git a/src/chartreuse/tests/e2e_tests/conftest.py b/src/chartreuse/tests/e2e_tests/conftest.py index c330701..4697a0a 100644 --- a/src/chartreuse/tests/e2e_tests/conftest.py +++ b/src/chartreuse/tests/e2e_tests/conftest.py @@ -51,7 +51,14 @@ def _cluster_init(include_chartreuse: bool, pre_upgrade: bool = False) -> Genera ) kubectl_port_forwardpostgresql = subprocess.Popen( - ["kubectl", "port-forward", "--namespace", TEST_NAMESPACE, f"{TEST_RELEASE}-postgresql-0", "5432"] + [ + "kubectl", + "port-forward", + "--namespace", + TEST_NAMESPACE, + f"{TEST_RELEASE}-postgresql-0", + "5432", + ] ) time.sleep(5) # Hack to wait for k exec to be up except: # noqa @@ -88,7 +95,9 @@ def prepare_container_image_and_helm_chart() -> None: cwd=HELM_CHART_PATH, ) - run_command("helm repo add wiremind https://wiremind.github.io/wiremind-helm-charts") + run_command( + "helm repo add wiremind https://wiremind.github.io/wiremind-helm-charts" + ) run_command( "helm install wiremind-crds wiremind/wiremind-crds --version 0.1.0", ) @@ -110,14 +119,22 @@ def populate_cluster_with_chartreuse_pre_upgrade() -> Generator: def assert_sql_upgraded() -> None: - assert inspect(sqlalchemy.create_engine(POSTGRESQL_URL)).get_table_names() == ["alembic_version", "upgraded"] + assert inspect(sqlalchemy.create_engine(POSTGRESQL_URL)).get_table_names() == [ + "alembic_version", + "upgraded", + ] def assert_sql_not_upgraded() -> None: - assert not inspect(sqlalchemy.create_engine(POSTGRESQL_URL)).get_table_names() == ["alembic_version", "upgraded"] + assert not inspect(sqlalchemy.create_engine(POSTGRESQL_URL)).get_table_names() == [ + "alembic_version", + "upgraded", + ] def are_pods_scaled_down() -> bool: return KubernetesDeploymentManager( - release_name=TEST_RELEASE, namespace=TEST_NAMESPACE, should_load_kubernetes_config=False + release_name=TEST_RELEASE, + namespace=TEST_NAMESPACE, + should_load_kubernetes_config=False, ).is_deployment_stopped("e2e-test-release-my-test-chart") diff --git a/src/chartreuse/tests/unit_tests/conftest.py b/src/chartreuse/tests/unit_tests/conftest.py index 2ab9813..75891d9 100644 --- a/src/chartreuse/tests/unit_tests/conftest.py +++ b/src/chartreuse/tests/unit_tests/conftest.py @@ -2,7 +2,9 @@ from pytest_mock.plugin import MockerFixture -def configure_chartreuse_mock(mocker: MockerFixture, is_migration_needed: bool = True) -> None: +def configure_chartreuse_mock( + mocker: MockerFixture, is_migration_needed: bool = True +) -> None: mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse.__init__", return_value=None) mocker.patch( "chartreuse.chartreuse_upgrade.Chartreuse.is_migration_needed", @@ -11,4 +13,7 @@ def configure_chartreuse_mock(mocker: MockerFixture, is_migration_needed: bool = create=True, ) mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse.upgrade") - mocker.patch("wiremind_kubernetes.kubernetes_helper._get_namespace_from_kube", return_value="foo") + mocker.patch( + "wiremind_kubernetes.kubernetes_helper._get_namespace_from_kube", + return_value="foo", + ) diff --git a/src/chartreuse/tests/unit_tests/test_alembic.py b/src/chartreuse/tests/unit_tests/test_alembic.py index f635e4e..87ff329 100644 --- a/src/chartreuse/tests/unit_tests/test_alembic.py +++ b/src/chartreuse/tests/unit_tests/test_alembic.py @@ -8,8 +8,13 @@ def test_respect_empty_database(mocker: MockerFixture) -> None: Test that is_postgres_empty returns empty even if alembic table exists """ table_list = ["alembic_version"] - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", + return_value=table_list, + ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", configure=False + ) assert alembic_migration_helper.is_postgres_empty() @@ -18,9 +23,17 @@ def test_respect_not_empty_database(mocker: MockerFixture) -> None: Test that is_postgres_empty return False when tables exist """ table_list = ["alembic_version", "foobar"] - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list) - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value="123") - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", + return_value=table_list, + ) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", + return_value="123", + ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", configure=False + ) assert alembic_migration_helper.is_postgres_empty() is False @@ -36,9 +49,17 @@ def test_detect_needed_migration(mocker: MockerFixture) -> None: e1f79bafdfa2 """ table_list = ["alembic_version", "foobar"] - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list) - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", + return_value=table_list, + ) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", + return_value=sample_alembic_output, + ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", configure=False + ) assert alembic_migration_helper.is_migration_needed @@ -55,9 +76,17 @@ def test_detect_not_needed_migration(mocker: MockerFixture) -> None: e1f79bafdfa2 (head) """ table_list = ["alembic_version", "foobar"] - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list) - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", + return_value=table_list, + ) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", + return_value=sample_alembic_output, + ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", configure=False + ) assert alembic_migration_helper.is_migration_needed is False @@ -69,9 +98,17 @@ def test_detect_needed_migration_non_existent(mocker: MockerFixture) -> None: sample_alembic_output = """ """ table_list = ["alembic_version", "foobar"] - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list) - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", + return_value=table_list, + ) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", + return_value=sample_alembic_output, + ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", configure=False + ) assert alembic_migration_helper.is_migration_needed @@ -83,10 +120,18 @@ def test_detect_database_is_empty(mocker: MockerFixture) -> None: sample_alembic_output = """ """ table_list = ["alembic_version"] - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list) - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", + return_value=table_list, + ) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", + return_value=sample_alembic_output, + ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", configure=False + ) assert alembic_migration_helper.is_postgres_empty() @@ -98,10 +143,18 @@ def test_detect_database_is_not_empty(mocker: MockerFixture) -> None: sample_alembic_output = """ """ table_list = ["alembic_version", "foobar"] - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list) - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", + return_value=table_list, + ) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", + return_value=sample_alembic_output, + ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", configure=False + ) assert alembic_migration_helper.is_postgres_empty() is False @@ -110,24 +163,37 @@ def test_additional_parameters(mocker: MockerFixture) -> None: """ Test that alembic additional parameters are respectedf for upgrade_db """ - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo") - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value="bar") + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo" + ) + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", + return_value="bar", + ) - mocked_run_command = mocker.patch("chartreuse.utils.alembic_migration_helper.run_command") + mocked_run_command = mocker.patch( + "chartreuse.utils.alembic_migration_helper.run_command" + ) alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( database_url="foo", configure=False, additional_parameters="foo bar" ) alembic_migration_helper.upgrade_db() - mocked_run_command.assert_called_with("alembic -c alembic.ini foo bar upgrade head", cwd="/app/alembic") + mocked_run_command.assert_called_with( + "alembic -c alembic.ini foo bar upgrade head", cwd="/app/alembic" + ) def test_additional_parameters_current(mocker: MockerFixture) -> None: """ Test that alembic additional parameters are respected in _get_alembic_current """ - mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo") - mocked_run_command = mocker.patch("chartreuse.utils.alembic_migration_helper.run_command") + mocker.patch( + "chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo" + ) + mocked_run_command = mocker.patch( + "chartreuse.utils.alembic_migration_helper.run_command" + ) mocked_run_command.return_value = ("bar", None, 0) alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py index c2b4ce3..1f17068 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py @@ -7,46 +7,64 @@ from .conftest import configure_chartreuse_mock -def test_chartreuse_upgrade_detected_migration_enabled_stop_pods(mocker: MockerFixture) -> None: +def test_chartreuse_upgrade_detected_migration_enabled_stop_pods( + mocker: MockerFixture, +) -> None: """ Test that chartreuse_upgrades stop pods in case of detected migration. """ configure_chartreuse_mock(mocker=mocker, is_migration_needed=True) - mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") + mocked_stop_pods = mocker.patch( + "wiremind_kubernetes.KubernetesDeploymentManager.stop_pods" + ) mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") - configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"}) + configure_os_environ_mock( + mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"} + ) chartreuse.chartreuse_upgrade.main() mocked_stop_pods.assert_called() -def test_chartreuse_upgrade_detected_migration_disabled_stop_pods(mocker: MockerFixture) -> None: +def test_chartreuse_upgrade_detected_migration_disabled_stop_pods( + mocker: MockerFixture, +) -> None: """ Test that chartreuse_upgrades does not stop pods in case of detected migration but we disallow stop-pods. """ configure_chartreuse_mock(mocker=mocker, is_migration_needed=True) - mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") + mocked_stop_pods = mocker.patch( + "wiremind_kubernetes.KubernetesDeploymentManager.stop_pods" + ) mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") configure_os_environ_mock( mocker=mocker, - additional_environment=dict(CHARTREUSE_ENABLE_STOP_PODS="", HELM_CHART_VERSION="5.0.0"), + additional_environment=dict( + CHARTREUSE_ENABLE_STOP_PODS="", HELM_CHART_VERSION="5.0.0" + ), ) chartreuse.chartreuse_upgrade.main() mocked_stop_pods.assert_not_called() -def test_chartreuse_upgrade_no_migration_disabled_stop_pods(mocker: MockerFixture) -> None: +def test_chartreuse_upgrade_no_migration_disabled_stop_pods( + mocker: MockerFixture, +) -> None: """ Test that chartreuse_upgrades does NOT stop pods in case of migration not needed. """ configure_chartreuse_mock(mocker=mocker, is_migration_needed=False) - mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") + mocked_stop_pods = mocker.patch( + "wiremind_kubernetes.KubernetesDeploymentManager.stop_pods" + ) mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") - configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"}) + configure_os_environ_mock( + mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"} + ) chartreuse.chartreuse_upgrade.main() mocked_stop_pods.assert_not_called() @@ -74,15 +92,26 @@ def test_chartreuse_upgrade_no_migration_disabled_stop_pods(mocker: MockerFixtur ], ) def test_chartreuse_upgrade_compatibility_check( - mocker: MockerFixture, helm_chart_version: str | None, package_version: str, should_raise: bool + mocker: MockerFixture, + helm_chart_version: str | None, + package_version: str, + should_raise: bool, ) -> None: """ Test that chartreuse_upgrade deals as expected with compatibility with the package version and the Helm Chart version. """ - mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value=package_version) - additional_environment = {"HELM_CHART_VERSION": helm_chart_version} if helm_chart_version is not None else {} - configure_os_environ_mock(mocker=mocker, additional_environment=additional_environment) + mocker.patch( + "chartreuse.chartreuse_upgrade.get_version", return_value=package_version + ) + additional_environment = ( + {"HELM_CHART_VERSION": helm_chart_version} + if helm_chart_version is not None + else {} + ) + configure_os_environ_mock( + mocker=mocker, additional_environment=additional_environment + ) configure_chartreuse_mock(mocker=mocker, is_migration_needed=False) if should_raise: diff --git a/src/chartreuse/utils/alembic_migration_helper.py b/src/chartreuse/utils/alembic_migration_helper.py index fdb8eed..bf5e6d2 100755 --- a/src/chartreuse/utils/alembic_migration_helper.py +++ b/src/chartreuse/utils/alembic_migration_helper.py @@ -44,17 +44,26 @@ def __init__( self.is_migration_needed = self._check_migration_needed() def _configure(self) -> None: - with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}") as f: + with open( + f"{self.alembic_directory_path}/{self.alembic_config_file_path}" + ) as f: content = f.read() content_new = re.sub( - "(sqlalchemy.url.*=.*){1}", r"sqlalchemy.url=%s" % self.database_url, content, flags=re.M + "(sqlalchemy.url.*=.*){1}", + r"sqlalchemy.url=%s" % self.database_url, + content, + flags=re.M, ) if content != content_new: - with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}", "w") as f: + with open( + f"{self.alembic_directory_path}/{self.alembic_config_file_path}", "w" + ) as f: f.write(content_new) logger.info("alembic.ini was configured.") else: - raise SubprocessError("configuration of alembic.ini has failed (alembic.ini is unchanged)") + raise SubprocessError( + "configuration of alembic.ini has failed (alembic.ini is unchanged)" + ) def _wait_postgres_is_configured(self) -> None: """ @@ -62,8 +71,12 @@ def _wait_postgres_is_configured(self) -> None: and that default privileges were configured. # TODO: Maybe make this a readinessProbe on Patroni PG Pods """ - wait_timeout = int(os.getenv("CHARTREUSE_ALEMBIC_POSTGRES_WAIT_CONFIGURED_TIMEOUT", 60)) - engine = sqlalchemy.create_engine(self.database_url, poolclass=NullPool, connect_args={"connect_timeout": 1}) + wait_timeout = int( + os.getenv("CHARTREUSE_ALEMBIC_POSTGRES_WAIT_CONFIGURED_TIMEOUT", 60) + ) + engine = sqlalchemy.create_engine( + self.database_url, poolclass=NullPool, connect_args={"connect_timeout": 1} + ) default_privileges_checks: list[str] = [ "SET ROLE wiremind_owner", # The real owner, alembic will switch to it before running migrations. @@ -81,7 +94,9 @@ def _wait_postgres_is_configured(self) -> None: with engine.connect() as connection: transac = connection.begin() # TODO: Use scalar_one() once sqlachemly >= 1.4 - _id = connection.execute(text(";".join(default_privileges_checks))).scalar() + _id = connection.execute( + text(";".join(default_privileges_checks)) + ).scalar() assert _id == 1 transac.rollback() logger.info( @@ -118,14 +133,18 @@ def is_postgres_empty(self) -> bool: def _get_alembic_current(self) -> str: command: str = f"alembic -c {self.alembic_config_file_path} {self.additional_parameters} current" - alembic_current, stderr, returncode = run_command(command, return_result=True, cwd=self.alembic_directory_path) + alembic_current, stderr, returncode = run_command( + command, return_result=True, cwd=self.alembic_directory_path + ) if returncode != 0: raise SubprocessError(f"{command} has failed: {alembic_current}, {stderr}") return alembic_current def _check_migration_needed(self) -> bool: if self.is_postgres_empty() and not self.allow_migration_for_empty_database: - logger.info("Database is not populated yet but migration for empty database is forbidden, not upgrading.") + logger.info( + "Database is not populated yet but migration for empty database is forbidden, not upgrading." + ) return False head_re = re.compile(r"^\w+ \(head\)$", re.MULTILINE) From a127f5adba5bae14ac4cad1b499797c2e6b52435 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:46:08 +0200 Subject: [PATCH 05/13] feat(dependencies): add toml file --- pyproject.toml | 72 +++++++++++++++++++++++++++++++++++++++++++++++- requirements.txt | 8 ++---- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 17de5f2..b494234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,76 @@ +[project] +name = "chartreuse" +description="Helper for Alembic migrations within Kubernetes." +dynamic = ["version"] +authors = [{ name = "wiremind", email = "dev@wiremind.io" }] +license="LGPL-3.0-or-later" +urls = { github = "https://github.com/wiremind/chartreuse"} +scripts = {chartreuse-upgrade = "chartreuse.chartreuse_upgrade:main"} +requires-python = ">=3.11.0" + +dependencies = [ + "alembic", + "psycopg2", + "wiremind-kubernetes~=7.0", +] + +[build-system] +requires = ["setuptools>=71.1"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +include-package-data = true +zip-safe = true +[tool.setuptools.dynamic] +version = { file = "VERSION" } +[tool.setuptools.packages.find] +where = ["src"] +exclude = ["*.tests", "*.tests.*", "tests.*", "tests"] + +[project.optional-dependencies] +test = [ + "mock", + "pytest", + "pytest-mock", +] +mypy = [ + "mypy", +] +dev = [ + "black", + "flake8", + "flake8-mutable", + "pip-tools", + + "mock", + "pytest", + "pytest-mock", + + "mypy" +] + [tool.black] line-length = 120 -target-version = ['py37'] +target-version = ['py311'] [tool.isort] line_length = 120 + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true +check_untyped_defs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unused_configs = true +no_implicit_optional = true +show_error_codes = true +files = [ "src", "example/alembic"] + +[tool.pytest.ini_options] +log_level = "INFO" +# Deterministic ordering for tests; useful for pytest-xdist. +env = ["PYTHONHASHSEED=0"] +filterwarnings = ["ignore::pytest.PytestUnknownMarkWarning"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 71c26cb..188217f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,5 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --no-emit-index-url --output-file=requirements.txt pyproject.toml -# +# This file was autogenerated by uv via the following command: +# uv pip compile --no-emit-index-url --output-file=requirements.txt pyproject.toml alembic==1.13.2 # via chartreuse (pyproject.toml) cachetools==5.4.0 From 6f68e709fb3d26df63e8511a9adbf7c9ab0c6202 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:11:26 +0200 Subject: [PATCH 06/13] feat(test): add more tests --- .../tests/unit_tests/test_chartreuse.py | 446 ++++++++++++++++ .../test_chartreuse_upgrade_extended.py | 505 ++++++++++++++++++ .../tests/unit_tests/test_config_loader.py | 266 +++++++++ 3 files changed, 1217 insertions(+) create mode 100644 src/chartreuse/tests/unit_tests/test_chartreuse.py create mode 100644 src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py create mode 100644 src/chartreuse/tests/unit_tests/test_config_loader.py diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse.py b/src/chartreuse/tests/unit_tests/test_chartreuse.py new file mode 100644 index 0000000..9023cc1 --- /dev/null +++ b/src/chartreuse/tests/unit_tests/test_chartreuse.py @@ -0,0 +1,446 @@ +"""Unit tests for chartreuse main module.""" + +import logging +from unittest.mock import MagicMock + +from pytest_mock.plugin import MockerFixture + +from chartreuse.chartreuse import Chartreuse, MultiChartreuse, configure_logging + + +class TestConfigureLogging: + """Test cases for configure_logging function.""" + + def test_configure_logging(self, mocker: MockerFixture) -> None: + """Test that logging is configured correctly.""" + mock_basic_config = mocker.patch("logging.basicConfig") + + configure_logging() + + mock_basic_config.assert_called_once_with( + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%H:%M:%S", + level=logging.INFO, + ) + + +class TestChartreuse: + """Test cases for Chartreuse class.""" + + def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> None: + """Test Chartreuse initialization with provided kubernetes helper.""" + # Mock AlembicMigrationHelper + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + mock_alembic_instance = MagicMock() + mock_alembic_instance.is_migration_needed = True + mock_alembic_helper.return_value = mock_alembic_instance + + # Mock configure_logging + mocker.patch("chartreuse.chartreuse.configure_logging") + + # Create a mock kubernetes helper + mock_k8s_helper = MagicMock() + + chartreuse = Chartreuse( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + postgresql_url="postgresql://user:pass@localhost:5432/db", + release_name="test-release", + alembic_allow_migration_for_empty_database=True, + alembic_additional_parameters="--verbose", + kubernetes_helper=mock_k8s_helper, + ) + + # Verify AlembicMigrationHelper was initialized correctly + mock_alembic_helper.assert_called_once_with( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + database_url="postgresql://user:pass@localhost:5432/db", + allow_migration_for_empty_database=True, + additional_parameters="--verbose", + ) + + # Verify kubernetes helper is set + assert chartreuse.kubernetes_helper == mock_k8s_helper + assert chartreuse.is_migration_needed is True + + def test_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) -> None: + """Test Chartreuse initialization without provided kubernetes helper.""" + # Mock AlembicMigrationHelper + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + mock_alembic_instance = MagicMock() + mock_alembic_instance.is_migration_needed = False + mock_alembic_helper.return_value = mock_alembic_instance + + # Mock KubernetesDeploymentManager + mock_k8s_manager = mocker.patch( + "wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager" + ) + mock_k8s_instance = MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + # Mock configure_logging + mocker.patch("chartreuse.chartreuse.configure_logging") + + chartreuse = Chartreuse( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + postgresql_url="postgresql://user:pass@localhost:5432/db", + release_name="test-release", + alembic_allow_migration_for_empty_database=False, + ) + + # Verify KubernetesDeploymentManager was initialized + mock_k8s_manager.assert_called_once_with( + use_kubeconfig=None, release_name="test-release" + ) + + assert chartreuse.kubernetes_helper == mock_k8s_instance + assert chartreuse.is_migration_needed is False + + def test_check_migration_needed(self, mocker: MockerFixture) -> None: + """Test check_migration_needed method.""" + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + mock_alembic_instance = MagicMock() + mock_alembic_instance.is_migration_needed = True + mock_alembic_helper.return_value = mock_alembic_instance + + mocker.patch("chartreuse.chartreuse.configure_logging") + mock_k8s_helper = MagicMock() + + chartreuse = Chartreuse( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + postgresql_url="postgresql://user:pass@localhost:5432/db", + release_name="test-release", + alembic_allow_migration_for_empty_database=True, + kubernetes_helper=mock_k8s_helper, + ) + + result = chartreuse.check_migration_needed() + assert result is True + + # Change the mock return value + mock_alembic_instance.is_migration_needed = False + result = chartreuse.check_migration_needed() + assert result is False + + def test_upgrade_when_migration_needed(self, mocker: MockerFixture) -> None: + """Test upgrade method when migration is needed.""" + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + mock_alembic_instance = MagicMock() + mock_alembic_instance.is_migration_needed = True + mock_alembic_helper.return_value = mock_alembic_instance + + mocker.patch("chartreuse.chartreuse.configure_logging") + mock_k8s_helper = MagicMock() + + chartreuse = Chartreuse( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + postgresql_url="postgresql://user:pass@localhost:5432/db", + release_name="test-release", + alembic_allow_migration_for_empty_database=True, + kubernetes_helper=mock_k8s_helper, + ) + + chartreuse.upgrade() + + # Verify upgrade_db was called + mock_alembic_instance.upgrade_db.assert_called_once() + + def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: + """Test upgrade method when migration is not needed.""" + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + mock_alembic_instance = MagicMock() + mock_alembic_instance.is_migration_needed = False + mock_alembic_helper.return_value = mock_alembic_instance + + mocker.patch("chartreuse.chartreuse.configure_logging") + mock_k8s_helper = MagicMock() + + chartreuse = Chartreuse( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + postgresql_url="postgresql://user:pass@localhost:5432/db", + release_name="test-release", + alembic_allow_migration_for_empty_database=True, + kubernetes_helper=mock_k8s_helper, + ) + + chartreuse.upgrade() + + # Verify upgrade_db was not called + mock_alembic_instance.upgrade_db.assert_not_called() + + +class TestMultiChartreuse: + """Test cases for MultiChartreuse class.""" + + def test_multi_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> None: + """Test MultiChartreuse initialization with provided kubernetes helper.""" + # Mock AlembicMigrationHelper + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + + # Create mock instances for different databases + mock_main_instance = MagicMock() + mock_main_instance.is_migration_needed = True + + mock_sec_instance = MagicMock() + mock_sec_instance.is_migration_needed = False + + # Configure the mock to return different instances for different calls + mock_alembic_helper.side_effect = [mock_main_instance, mock_sec_instance] + + # Mock configure_logging + mocker.patch("chartreuse.chartreuse.configure_logging") + + # Create a mock kubernetes helper + mock_k8s_helper = MagicMock() + + databases_config = [ + { + "name": "main", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5432/maindb", + "allow_migration_for_empty_database": True, + "additional_parameters": "--verbose", + }, + { + "name": "secondary", + "alembic_directory_path": "/app/alembic/secondary", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5433/secdb", + }, + ] + + multi_chartreuse = MultiChartreuse( + databases_config=databases_config, + release_name="test-release", + kubernetes_helper=mock_k8s_helper, + ) + + # Verify AlembicMigrationHelper was called for each database + assert mock_alembic_helper.call_count == 2 + + # Verify migration helpers are stored correctly + assert "main" in multi_chartreuse.migration_helpers + assert "secondary" in multi_chartreuse.migration_helpers + assert multi_chartreuse.migration_helpers["main"] == mock_main_instance + assert multi_chartreuse.migration_helpers["secondary"] == mock_sec_instance + + # Verify kubernetes helper is set + assert multi_chartreuse.kubernetes_helper == mock_k8s_helper + + # Verify migration is needed (because main db needs migration) + assert multi_chartreuse.is_migration_needed is True + + def test_multi_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) -> None: + """Test MultiChartreuse initialization without provided kubernetes helper.""" + # Mock AlembicMigrationHelper + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + mock_alembic_instance = MagicMock() + mock_alembic_instance.is_migration_needed = False + mock_alembic_helper.return_value = mock_alembic_instance + + # Mock KubernetesDeploymentManager + mock_k8s_manager = mocker.patch( + "wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager" + ) + mock_k8s_instance = MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + # Mock configure_logging + mocker.patch("chartreuse.chartreuse.configure_logging") + + databases_config = [ + { + "name": "test", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5432/db", + } + ] + + multi_chartreuse = MultiChartreuse( + databases_config=databases_config, + release_name="test-release", + ) + + # Verify KubernetesDeploymentManager was initialized + mock_k8s_manager.assert_called_once_with( + use_kubeconfig=None, release_name="test-release" + ) + + assert multi_chartreuse.kubernetes_helper == mock_k8s_instance + + def test_check_migration_needed_with_mixed_needs(self, mocker: MockerFixture) -> None: + """Test check_migration_needed with some databases needing migration.""" + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + + # Create mock instances + mock_main_instance = MagicMock() + mock_main_instance.is_migration_needed = False + + mock_sec_instance = MagicMock() + mock_sec_instance.is_migration_needed = True + + mock_alembic_helper.side_effect = [mock_main_instance, mock_sec_instance] + + mocker.patch("chartreuse.chartreuse.configure_logging") + mock_k8s_helper = MagicMock() + + databases_config = [ + { + "name": "main", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5432/maindb", + }, + { + "name": "secondary", + "alembic_directory_path": "/app/alembic/secondary", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5433/secdb", + }, + ] + + multi_chartreuse = MultiChartreuse( + databases_config=databases_config, + release_name="test-release", + kubernetes_helper=mock_k8s_helper, + ) + + # Should return True because secondary needs migration + result = multi_chartreuse.check_migration_needed() + assert result is True + + def test_check_migration_needed_none_need_migration(self, mocker: MockerFixture) -> None: + """Test check_migration_needed when no databases need migration.""" + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + + mock_instance = MagicMock() + mock_instance.is_migration_needed = False + mock_alembic_helper.return_value = mock_instance + + mocker.patch("chartreuse.chartreuse.configure_logging") + mock_k8s_helper = MagicMock() + + databases_config = [ + { + "name": "test", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5432/db", + } + ] + + multi_chartreuse = MultiChartreuse( + databases_config=databases_config, + release_name="test-release", + kubernetes_helper=mock_k8s_helper, + ) + + result = multi_chartreuse.check_migration_needed() + assert result is False + + def test_upgrade_only_needed_databases(self, mocker: MockerFixture) -> None: + """Test upgrade method only upgrades databases that need migration.""" + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + + # Create mock instances + mock_main_instance = MagicMock() + mock_main_instance.is_migration_needed = True + + mock_sec_instance = MagicMock() + mock_sec_instance.is_migration_needed = False + + mock_alembic_helper.side_effect = [mock_main_instance, mock_sec_instance] + + mocker.patch("chartreuse.chartreuse.configure_logging") + mock_k8s_helper = MagicMock() + + databases_config = [ + { + "name": "main", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5432/maindb", + }, + { + "name": "secondary", + "alembic_directory_path": "/app/alembic/secondary", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5433/secdb", + }, + ] + + multi_chartreuse = MultiChartreuse( + databases_config=databases_config, + release_name="test-release", + kubernetes_helper=mock_k8s_helper, + ) + + multi_chartreuse.upgrade() + + # Verify only main database was upgraded + mock_main_instance.upgrade_db.assert_called_once() + mock_sec_instance.upgrade_db.assert_not_called() + + def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: + """Test that default values are handled correctly for optional parameters.""" + mock_alembic_helper = mocker.patch( + "chartreuse.chartreuse.AlembicMigrationHelper" + ) + mock_alembic_instance = MagicMock() + mock_alembic_instance.is_migration_needed = False + mock_alembic_helper.return_value = mock_alembic_instance + + mocker.patch("chartreuse.chartreuse.configure_logging") + mock_k8s_helper = MagicMock() + + databases_config = [ + { + "name": "test", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + "url": "postgresql://user:pass@localhost:5432/db", + # No allow_migration_for_empty_database or additional_parameters + } + ] + + MultiChartreuse( + databases_config=databases_config, + release_name="test-release", + kubernetes_helper=mock_k8s_helper, + ) + + # Verify AlembicMigrationHelper was called with defaults + mock_alembic_helper.assert_called_once_with( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + database_url="postgresql://user:pass@localhost:5432/db", + allow_migration_for_empty_database=False, # Default value + additional_parameters="", # Default value + ) \ No newline at end of file diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py new file mode 100644 index 0000000..42c9ddb --- /dev/null +++ b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py @@ -0,0 +1,505 @@ +"""Extended unit tests for chartreuse_upgrade module.""" + +import os + +import pytest +from pytest_mock.plugin import MockerFixture + +from chartreuse.chartreuse_upgrade import ensure_safe_run, main + + +class TestEnsureSafeRun: + """Test cases for ensure_safe_run function.""" + + def test_ensure_safe_run_matching_versions(self, mocker: MockerFixture) -> None: + """Test ensure_safe_run with matching major.minor versions.""" + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.2.1") + mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0"}) + + # Should not raise any exception + ensure_safe_run() + + def test_ensure_safe_run_mismatched_versions(self, mocker: MockerFixture) -> None: + """Test ensure_safe_run with mismatched major.minor versions.""" + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.1.3") + mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0"}) + + with pytest.raises(ValueError) as exc_info: + ensure_safe_run() + + assert "don't have the same 'major.minor'" in str(exc_info.value) + assert "5.1.3" in str(exc_info.value) + assert "5.2.0" in str(exc_info.value) + + def test_ensure_safe_run_missing_helm_version(self, mocker: MockerFixture) -> None: + """Test ensure_safe_run with missing HELM_CHART_VERSION environment variable.""" + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.1.3") + mocker.patch.dict(os.environ, {}, clear=True) + + with pytest.raises(ValueError) as exc_info: + ensure_safe_run() + + assert "Couldn't get the Chartreuse's Helm Chart version" in str(exc_info.value) + + def test_ensure_safe_run_empty_helm_version(self, mocker: MockerFixture) -> None: + """Test ensure_safe_run with empty HELM_CHART_VERSION environment variable.""" + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.1.3") + mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": ""}) + + with pytest.raises(ValueError) as exc_info: + ensure_safe_run() + + assert "Couldn't get the Chartreuse's Helm Chart version" in str(exc_info.value) + + def test_ensure_safe_run_different_major_versions(self, mocker: MockerFixture) -> None: + """Test ensure_safe_run with different major versions.""" + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="4.2.1") + mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0"}) + + with pytest.raises(ValueError) as exc_info: + ensure_safe_run() + + assert "(['5', '2'] != ['4', '2'])" in str(exc_info.value) + + def test_ensure_safe_run_complex_version_formats(self, mocker: MockerFixture) -> None: + """Test ensure_safe_run with complex version formats.""" + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.2.1-alpha.1") + mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0-beta.2"}) + + # Should not raise any exception as major.minor match + ensure_safe_run() + + +class TestMainMultiDatabase: + """Test cases for main function with multi-database configuration.""" + + def test_main_multi_database_success(self, mocker: MockerFixture) -> None: + """Test main function with successful multi-database configuration.""" + # Mock ensure_safe_run + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + # Mock config loading + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/maindb", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + } + ] + mock_load_config = mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + + # Mock MultiChartreuse + mock_multi_chartreuse = mocker.patch( + "chartreuse.chartreuse_upgrade.MultiChartreuse" + ) + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = True + mock_multi_chartreuse.return_value = mock_chartreuse_instance + + # Mock KubernetesDeploymentManager + mock_k8s_manager = mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + mock_k8s_instance = mocker.MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + # Set up environment + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", + "CHARTREUSE_ENABLE_STOP_PODS": "true", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", + "HELM_IS_INSTALL": "false", + }, + ) + + main() + + # Verify mocks were called correctly + mock_load_config.assert_called_once_with("/app/config.yaml") + mock_multi_chartreuse.assert_called_once() + mock_chartreuse_instance.upgrade.assert_called_once() + mock_k8s_instance.stop_pods.assert_called_once() + mock_k8s_instance.start_pods.assert_called_once() + + def test_main_multi_database_no_migration_needed(self, mocker: MockerFixture) -> None: + """Test main function when no migration is needed.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/maindb", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + } + ] + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + + mock_multi_chartreuse = mocker.patch( + "chartreuse.chartreuse_upgrade.MultiChartreuse" + ) + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = False + mock_multi_chartreuse.return_value = mock_chartreuse_instance + + mock_k8s_manager = mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + mock_k8s_instance = mocker.MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", + "CHARTREUSE_ENABLE_STOP_PODS": "true", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", + "HELM_IS_INSTALL": "false", + }, + ) + + main() + + # Verify no pods were stopped/started and no upgrade occurred + mock_chartreuse_instance.upgrade.assert_not_called() + mock_k8s_instance.stop_pods.assert_not_called() + mock_k8s_instance.start_pods.assert_not_called() + + def test_main_multi_database_config_load_failure(self, mocker: MockerFixture) -> None: + """Test main function with config loading failure.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + side_effect=FileNotFoundError("Config file not found"), + ) + + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/missing_config.yaml", + "CHARTREUSE_RELEASE_NAME": "test-release", + }, + ) + + with pytest.raises(FileNotFoundError): + main() + + def test_main_multi_database_stop_pods_disabled(self, mocker: MockerFixture) -> None: + """Test main function with ENABLE_STOP_PODS disabled.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/maindb", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + } + ] + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + + mock_multi_chartreuse = mocker.patch( + "chartreuse.chartreuse_upgrade.MultiChartreuse" + ) + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = True + mock_multi_chartreuse.return_value = mock_chartreuse_instance + + mock_k8s_manager = mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + mock_k8s_instance = mocker.MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", + "CHARTREUSE_ENABLE_STOP_PODS": "false", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", + "HELM_IS_INSTALL": "false", + }, + ) + + main() + + # Verify upgrade was called but pods were not stopped/started + mock_chartreuse_instance.upgrade.assert_called_once() + mock_k8s_instance.stop_pods.assert_not_called() + mock_k8s_instance.start_pods.assert_not_called() + + def test_main_multi_database_upgrade_before_deployment(self, mocker: MockerFixture) -> None: + """Test main function with UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/maindb", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + } + ] + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + + mock_multi_chartreuse = mocker.patch( + "chartreuse.chartreuse_upgrade.MultiChartreuse" + ) + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = True + mock_multi_chartreuse.return_value = mock_chartreuse_instance + + mock_k8s_manager = mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + mock_k8s_instance = mocker.MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", + "CHARTREUSE_ENABLE_STOP_PODS": "true", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "true", + "HELM_IS_INSTALL": "false", + }, + ) + + main() + + # Verify pods were stopped and upgrade was called, but start_pods was not called + mock_chartreuse_instance.upgrade.assert_called_once() + mock_k8s_instance.stop_pods.assert_called_once() + mock_k8s_instance.start_pods.assert_not_called() + + def test_main_multi_database_start_pods_failure(self, mocker: MockerFixture) -> None: + """Test main function when start_pods fails.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/maindb", + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + } + ] + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + + mock_multi_chartreuse = mocker.patch( + "chartreuse.chartreuse_upgrade.MultiChartreuse" + ) + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = True + mock_multi_chartreuse.return_value = mock_chartreuse_instance + + mock_k8s_manager = mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + mock_k8s_instance = mocker.MagicMock() + mock_k8s_instance.start_pods.side_effect = Exception("Failed to start pods") + mock_k8s_manager.return_value = mock_k8s_instance + + # Mock logger to verify error was logged + mock_logger = mocker.patch("chartreuse.chartreuse_upgrade.logger") + + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", + "CHARTREUSE_ENABLE_STOP_PODS": "true", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", + "HELM_IS_INSTALL": "false", + }, + ) + + # Should not raise exception, just log error + main() + + # Verify error was logged + mock_logger.error.assert_called_once() + assert "Couldn't scale up new pods" in str(mock_logger.error.call_args) + + +class TestMainSingleDatabase: + """Test cases for main function with single-database configuration.""" + + def test_main_single_database_success(self, mocker: MockerFixture) -> None: + """Test main function with successful single-database configuration.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = True + mock_chartreuse.return_value = mock_chartreuse_instance + + mock_k8s_manager = mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + mock_k8s_instance = mocker.MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + # Set up environment for single-database mode (no CHARTREUSE_MULTI_CONFIG_PATH) + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_ALEMBIC_DIRECTORY_PATH": "/app/alembic", + "CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH": "alembic.ini", + "CHARTREUSE_ALEMBIC_URL": "postgresql://user:pass@localhost:5432/db", + "CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE": "true", + "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS": "--verbose", + "CHARTREUSE_ENABLE_STOP_PODS": "true", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", + "HELM_IS_INSTALL": "false", + }, + clear=True, + ) + + main() + + # Verify Chartreuse was initialized correctly + mock_chartreuse.assert_called_once_with( + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + postgresql_url="postgresql://user:pass@localhost:5432/db", + alembic_allow_migration_for_empty_database=True, + alembic_additional_parameters="--verbose", + release_name="test-release", + kubernetes_helper=mock_k8s_instance, + ) + + # Verify upgrade flow + mock_chartreuse_instance.upgrade.assert_called_once() + mock_k8s_instance.stop_pods.assert_called_once() + mock_k8s_instance.start_pods.assert_called_once() + + def test_main_single_database_default_values(self, mocker: MockerFixture) -> None: + """Test main function with default values for optional environment variables.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = False + mock_chartreuse.return_value = mock_chartreuse_instance + + mock_k8s_manager = mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + mock_k8s_instance = mocker.MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance + + # Set minimal required environment variables + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_ALEMBIC_URL": "postgresql://user:pass@localhost:5432/db", + "CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE": "false", + "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS": "", + "CHARTREUSE_ENABLE_STOP_PODS": "false", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", + "HELM_IS_INSTALL": "false", + }, + clear=True, + ) + + main() + + # Verify Chartreuse was initialized with default values + # Note: The boolean parsing in single-database mode uses bool(env_var) + # which means any non-empty string is True + mock_chartreuse.assert_called_once_with( + alembic_directory_path="/app/alembic", # Default value + alembic_config_file_path="alembic.ini", # Default value + postgresql_url="postgresql://user:pass@localhost:5432/db", + alembic_allow_migration_for_empty_database=True, # "false" -> bool("false") -> True + alembic_additional_parameters="", + release_name="test-release", + kubernetes_helper=mock_k8s_instance, + ) + + # No migration needed, so no pods operations + mock_chartreuse_instance.upgrade.assert_not_called() + mock_k8s_instance.stop_pods.assert_not_called() + mock_k8s_instance.start_pods.assert_not_called() + + +class TestMainBooleanParsing: + """Test cases for boolean environment variable parsing.""" + + @pytest.mark.parametrize( + "bool_str,expected", + [ + ("true", True), + ("TRUE", True), + ("True", True), + ("false", True), # bool("false") -> True (non-empty string) + ("FALSE", True), # bool("FALSE") -> True (non-empty string) + ("False", True), # bool("False") -> True (non-empty string) + ("", False), # bool("") -> False (empty string) + ("0", True), # bool("0") -> True (non-empty string) + ("1", True), # bool("1") -> True (non-empty string) + ], + ) + def test_boolean_parsing_variations( + self, mocker: MockerFixture, bool_str: str, expected: bool + ) -> None: + """Test various boolean string parsing in environment variables.""" + mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") + mock_chartreuse_instance = mocker.MagicMock() + mock_chartreuse_instance.is_migration_needed = False + mock_chartreuse.return_value = mock_chartreuse_instance + + mocker.patch( + "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" + ) + + # Set up environment for single-database mode (no CHARTREUSE_MULTI_CONFIG_PATH) + # Note: In single-database mode, boolean parsing uses bool(env_var) + # which means any non-empty string is True, empty string is False + mocker.patch.dict( + os.environ, + { + "CHARTREUSE_ALEMBIC_URL": "postgresql://user:pass@localhost:5432/db", + "CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE": bool_str, + "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS": "", + "CHARTREUSE_ENABLE_STOP_PODS": "false", + "CHARTREUSE_RELEASE_NAME": "test-release", + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", + "HELM_IS_INSTALL": "false", + }, + clear=True, + ) + + main() + + # Check that the boolean was parsed correctly + call_args = mock_chartreuse.call_args[1] + assert call_args["alembic_allow_migration_for_empty_database"] == expected \ No newline at end of file diff --git a/src/chartreuse/tests/unit_tests/test_config_loader.py b/src/chartreuse/tests/unit_tests/test_config_loader.py new file mode 100644 index 0000000..77f3853 --- /dev/null +++ b/src/chartreuse/tests/unit_tests/test_config_loader.py @@ -0,0 +1,266 @@ +"""Unit tests for config_loader module.""" + +import os +import tempfile + +import pytest +import yaml + +from chartreuse.config_loader import build_database_url, load_multi_database_config + + +class TestBuildDatabaseUrl: + """Test cases for build_database_url function.""" + + def test_build_database_url_success(self) -> None: + """Test successful database URL building with all required components.""" + db_config = { + "dialect": "postgresql", + "user": "testuser", + "password": "testpass", + "host": "localhost", + "port": "5432", + "database": "testdb", + } + + expected_url = "postgresql://testuser:testpass@localhost:5432/testdb" + result = build_database_url(db_config) + + assert result == expected_url + + def test_build_database_url_missing_components(self) -> None: + """Test that missing components raise ValueError.""" + db_config = { + "dialect": "postgresql", + "user": "testuser", + # Missing password, host, port, database + } + + with pytest.raises(ValueError) as exc_info: + build_database_url(db_config) + + assert "Missing required components" in str(exc_info.value) + assert "password" in str(exc_info.value) + assert "host" in str(exc_info.value) + assert "port" in str(exc_info.value) + assert "database" in str(exc_info.value) + + def test_build_database_url_empty_components(self) -> None: + """Test that empty string components are treated as missing.""" + db_config = { + "dialect": "postgresql", + "user": "", # Empty string + "password": "testpass", + "host": "localhost", + "port": "5432", + "database": "testdb", + } + + with pytest.raises(ValueError) as exc_info: + build_database_url(db_config) + + assert "Missing required components" in str(exc_info.value) + assert "user" in str(exc_info.value) + + def test_build_database_url_with_special_characters(self) -> None: + """Test URL building with special characters in password.""" + db_config = { + "dialect": "postgresql", + "user": "test@user", + "password": "test!@#$%pass", + "host": "localhost", + "port": "5432", + "database": "test-db", + } + + expected_url = "postgresql://test@user:test!@#$%pass@localhost:5432/test-db" + result = build_database_url(db_config) + + assert result == expected_url + + +class TestLoadMultiDatabaseConfig: + """Test cases for load_multi_database_config function.""" + + def test_load_multi_database_config_success(self) -> None: + """Test successful loading of multi-database configuration.""" + config_data = { + "databases": { + "main": { + "alembic_directory_path": "/app/alembic/main", + "alembic_config_file_path": "alembic.ini", + "dialect": "postgresql", + "user": "mainuser", + "password": "mainpass", + "host": "main.db.com", + "port": "5432", + "database": "maindb", + "allow_migration_for_empty_database": True, + "additional_parameters": "--verbose", + }, + "secondary": { + "alembic_directory_path": "/app/alembic/secondary", + "alembic_config_file_path": "alembic.ini", + "dialect": "postgresql", + "user": "secuser", + "password": "secpass", + "host": "sec.db.com", + "port": "5433", + "database": "secdb", + "allow_migration_for_empty_database": False, + }, + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(config_data, f) + temp_file = f.name + + try: + result = load_multi_database_config(temp_file) + + assert len(result) == 2 + + # Check main database config + main_db = next(db for db in result if db["name"] == "main") + assert main_db["alembic_directory_path"] == "/app/alembic/main" + assert main_db["url"] == "postgresql://mainuser:mainpass@main.db.com:5432/maindb" + assert main_db["allow_migration_for_empty_database"] is True + assert main_db["additional_parameters"] == "--verbose" + + # Check secondary database config + sec_db = next(db for db in result if db["name"] == "secondary") + assert sec_db["alembic_directory_path"] == "/app/alembic/secondary" + assert sec_db["url"] == "postgresql://secuser:secpass@sec.db.com:5433/secdb" + assert sec_db["allow_migration_for_empty_database"] is False + assert sec_db.get("additional_parameters", "") == "" + + finally: + os.unlink(temp_file) + + def test_load_multi_database_config_missing_databases_key(self) -> None: + """Test that missing 'databases' key raises ValueError.""" + config_data = { + "some_other_key": { + "value": "test" + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(config_data, f) + temp_file = f.name + + try: + with pytest.raises(ValueError) as exc_info: + load_multi_database_config(temp_file) + + assert "Configuration must contain 'databases' key" in str(exc_info.value) + finally: + os.unlink(temp_file) + + def test_load_multi_database_config_databases_not_dict(self) -> None: + """Test that non-dict 'databases' value raises ValueError.""" + config_data = { + "databases": ["not", "a", "dict"] + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(config_data, f) + temp_file = f.name + + try: + with pytest.raises(ValueError) as exc_info: + load_multi_database_config(temp_file) + + assert "'databases' must be a dictionary" in str(exc_info.value) + finally: + os.unlink(temp_file) + + def test_load_multi_database_config_missing_required_components(self) -> None: + """Test that missing database components raise ValueError.""" + config_data = { + "databases": { + "incomplete": { + "alembic_directory_path": "/app/alembic", + "dialect": "postgresql", + "user": "user", + # Missing password, host, port, database + } + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(config_data, f) + temp_file = f.name + + try: + with pytest.raises(ValueError) as exc_info: + load_multi_database_config(temp_file) + + assert "Missing required components" in str(exc_info.value) + finally: + os.unlink(temp_file) + + def test_load_multi_database_config_file_not_found(self) -> None: + """Test that missing config file raises FileNotFoundError.""" + non_existent_file = "/path/that/does/not/exist.yaml" + + with pytest.raises(FileNotFoundError) as exc_info: + load_multi_database_config(non_existent_file) + + assert f"Configuration file not found: {non_existent_file}" in str(exc_info.value) + + def test_load_multi_database_config_invalid_yaml(self) -> None: + """Test that invalid YAML raises ValueError.""" + invalid_yaml = """ + databases: + main: + key: value + invalid: [unclosed bracket + """ + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write(invalid_yaml) + temp_file = f.name + + try: + with pytest.raises(ValueError) as exc_info: + load_multi_database_config(temp_file) + + assert "Invalid YAML in configuration file" in str(exc_info.value) + finally: + os.unlink(temp_file) + + def test_load_multi_database_config_minimal_config(self) -> None: + """Test loading with minimal required configuration.""" + config_data = { + "databases": { + "minimal": { + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + "dialect": "postgresql", + "user": "user", + "password": "pass", + "host": "localhost", + "port": "5432", + "database": "db", + } + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(config_data, f) + temp_file = f.name + + try: + result = load_multi_database_config(temp_file) + + assert len(result) == 1 + db_config = result[0] + assert db_config["name"] == "minimal" + assert db_config["url"] == "postgresql://user:pass@localhost:5432/db" + assert db_config.get("allow_migration_for_empty_database", False) is False + assert db_config.get("additional_parameters", "") == "" + + finally: + os.unlink(temp_file) \ No newline at end of file From 3fe254d20b59ea43fa7247e1e3cca61ee6b63504 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:12:52 +0200 Subject: [PATCH 07/13] docs(changelog): update changelog --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d44713..3c84256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v5.1.0 (2025-10-07) + +### Feat +- add support for multi-database migration + + ## v5.0.0 (2025-05-05) ### BREAKING CHANGE - Change minimum supported version of python: 3.11 (drop 3.7, 3.8, 3.9, 3.10) diff --git a/VERSION b/VERSION index 0062ac9..831446c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.0.0 +5.1.0 From bcc2076ad0cad58e393f0cb518bf0615dcb0b6c1 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:17:39 +0200 Subject: [PATCH 08/13] fix(deps): fix toml file --- pyproject.toml | 1 + scripts/validate_config.py | 2 +- src/chartreuse/chartreuse_upgrade.py | 26 +-- src/chartreuse/config_loader.py | 1 - .../tests/e2e_tests/test_blackbox.py | 48 ++--- .../tests/unit_tests/test_chartreuse.py | 166 ++++++++++-------- .../test_chartreuse_upgrade_extended.py | 166 ++++++++++-------- .../tests/unit_tests/test_config_loader.py | 73 ++++---- 8 files changed, 254 insertions(+), 229 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b494234..0db4aec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ name = "chartreuse" description="Helper for Alembic migrations within Kubernetes." dynamic = ["version"] +readme = "README.md" authors = [{ name = "wiremind", email = "dev@wiremind.io" }] license="LGPL-3.0-or-later" urls = { github = "https://github.com/wiremind/chartreuse"} diff --git a/scripts/validate_config.py b/scripts/validate_config.py index 7c9b021..0513768 100644 --- a/scripts/validate_config.py +++ b/scripts/validate_config.py @@ -123,7 +123,7 @@ def validate_config(config_path: str) -> bool: return False -def main(): +def main() -> None: if len(sys.argv) != 2: print("Usage: python validate_config.py path/to/config.yaml") sys.exit(1) diff --git a/src/chartreuse/chartreuse_upgrade.py b/src/chartreuse/chartreuse_upgrade.py index 446368f..3cbc52d 100755 --- a/src/chartreuse/chartreuse_upgrade.py +++ b/src/chartreuse/chartreuse_upgrade.py @@ -102,41 +102,41 @@ def main() -> None: ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get( "CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini" ) - POSTGRESQL_URL: str = os.environ.get("CHARTREUSE_ALEMBIC_URL") + POSTGRESQL_URL: str | None = os.environ["CHARTREUSE_ALEMBIC_URL"] ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE: bool = bool( os.environ["CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE"] ) ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ[ "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS" ] - ENABLE_STOP_PODS: bool = bool(os.environ["CHARTREUSE_ENABLE_STOP_PODS"]) - RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] - UPGRADE_BEFORE_DEPLOYMENT: bool = bool( + SINGLE_ENABLE_STOP_PODS: bool = bool(os.environ["CHARTREUSE_ENABLE_STOP_PODS"]) + SINGLE_RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] + SINGLE_UPGRADE_BEFORE_DEPLOYMENT: bool = bool( os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"] ) - HELM_IS_INSTALL: bool = bool(os.environ["HELM_IS_INSTALL"]) + SINGLE_HELM_IS_INSTALL: bool = bool(os.environ["HELM_IS_INSTALL"]) deployment_manager = KubernetesDeploymentManager( - release_name=RELEASE_NAME, use_kubeconfig=None + release_name=SINGLE_RELEASE_NAME, use_kubeconfig=None ) - chartreuse = Chartreuse( + single_chartreuse = Chartreuse( alembic_directory_path=ALEMBIC_DIRECTORY_PATH, alembic_config_file_path=ALEMBIC_CONFIG_FILE_PATH, postgresql_url=POSTGRESQL_URL, alembic_allow_migration_for_empty_database=ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE, alembic_additional_parameters=ALEMBIC_ADDITIONAL_PARAMETERS, - release_name=RELEASE_NAME, + release_name=SINGLE_RELEASE_NAME, kubernetes_helper=deployment_manager, ) - if chartreuse.is_migration_needed: - if ENABLE_STOP_PODS: + if single_chartreuse.is_migration_needed: + if SINGLE_ENABLE_STOP_PODS: deployment_manager.stop_pods() - chartreuse.upgrade() + single_chartreuse.upgrade() - if not ENABLE_STOP_PODS: + if not SINGLE_ENABLE_STOP_PODS: return - if UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL: + if SINGLE_UPGRADE_BEFORE_DEPLOYMENT and not SINGLE_HELM_IS_INSTALL: return try: diff --git a/src/chartreuse/config_loader.py b/src/chartreuse/config_loader.py index e108395..137dc49 100644 --- a/src/chartreuse/config_loader.py +++ b/src/chartreuse/config_loader.py @@ -3,7 +3,6 @@ """ import logging -import os from typing import Dict, List import yaml diff --git a/src/chartreuse/tests/e2e_tests/test_blackbox.py b/src/chartreuse/tests/e2e_tests/test_blackbox.py index 328fa65..cc48ffd 100644 --- a/src/chartreuse/tests/e2e_tests/test_blackbox.py +++ b/src/chartreuse/tests/e2e_tests/test_blackbox.py @@ -1,31 +1,31 @@ -import logging +# import logging -from pytest_mock.plugin import MockerFixture +# from pytest_mock.plugin import MockerFixture -from .conftest import are_pods_scaled_down, assert_sql_upgraded +# from .conftest import are_pods_scaled_down, assert_sql_upgraded -logger = logging.getLogger(__name__) +# logger = logging.getLogger(__name__) -# def test_chartreuse_blackbox_post_upgrade( -# prepare_container_image_and_helm_chart: MockerFixture, -# populate_cluster_with_chartreuse_post_upgrade: MockerFixture, -# mocker: MockerFixture, -# ) -> None: -# """ -# Test chartreuse considered as a blackbox, in post-install,post-upgrade configuration -# """ -# assert_sql_upgraded() -# assert not are_pods_scaled_down() +# # def test_chartreuse_blackbox_post_upgrade( +# # prepare_container_image_and_helm_chart: MockerFixture, +# # populate_cluster_with_chartreuse_post_upgrade: MockerFixture, +# # mocker: MockerFixture, +# # ) -> None: +# # """ +# # Test chartreuse considered as a blackbox, in post-install,post-upgrade configuration +# # """ +# # assert_sql_upgraded() +# # assert not are_pods_scaled_down() -# def test_chartreuse_blackbox_pre_upgrade( -# prepare_container_image_and_helm_chart: MockerFixture, -# populate_cluster_with_chartreuse_pre_upgrade: MockerFixture, -# mocker: MockerFixture, -# ) -> None: -# """ -# Test chartreuse considered as a blackbox, in pre-upgrade configuration -# """ -# assert_sql_upgraded() -# assert not are_pods_scaled_down() +# # def test_chartreuse_blackbox_pre_upgrade( +# # prepare_container_image_and_helm_chart: MockerFixture, +# # populate_cluster_with_chartreuse_pre_upgrade: MockerFixture, +# # mocker: MockerFixture, +# # ) -> None: +# # """ +# # Test chartreuse considered as a blackbox, in pre-upgrade configuration +# # """ +# # assert_sql_upgraded() +# # assert not are_pods_scaled_down() diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse.py b/src/chartreuse/tests/unit_tests/test_chartreuse.py index 9023cc1..796c419 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse.py @@ -14,9 +14,9 @@ class TestConfigureLogging: def test_configure_logging(self, mocker: MockerFixture) -> None: """Test that logging is configured correctly.""" mock_basic_config = mocker.patch("logging.basicConfig") - + configure_logging() - + mock_basic_config.assert_called_once_with( format="%(asctime)s %(levelname)s: %(message)s", datefmt="%H:%M:%S", @@ -27,7 +27,9 @@ def test_configure_logging(self, mocker: MockerFixture) -> None: class TestChartreuse: """Test cases for Chartreuse class.""" - def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> None: + def test_chartreuse_init_with_kubernetes_helper( + self, mocker: MockerFixture + ) -> None: """Test Chartreuse initialization with provided kubernetes helper.""" # Mock AlembicMigrationHelper mock_alembic_helper = mocker.patch( @@ -36,13 +38,13 @@ def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = True mock_alembic_helper.return_value = mock_alembic_instance - + # Mock configure_logging mocker.patch("chartreuse.chartreuse.configure_logging") - + # Create a mock kubernetes helper mock_k8s_helper = MagicMock() - + chartreuse = Chartreuse( alembic_directory_path="/app/alembic", alembic_config_file_path="alembic.ini", @@ -52,7 +54,7 @@ def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> alembic_additional_parameters="--verbose", kubernetes_helper=mock_k8s_helper, ) - + # Verify AlembicMigrationHelper was initialized correctly mock_alembic_helper.assert_called_once_with( alembic_directory_path="/app/alembic", @@ -61,12 +63,14 @@ def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> allow_migration_for_empty_database=True, additional_parameters="--verbose", ) - + # Verify kubernetes helper is set assert chartreuse.kubernetes_helper == mock_k8s_helper assert chartreuse.is_migration_needed is True - def test_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) -> None: + def test_chartreuse_init_without_kubernetes_helper( + self, mocker: MockerFixture + ) -> None: """Test Chartreuse initialization without provided kubernetes helper.""" # Mock AlembicMigrationHelper mock_alembic_helper = mocker.patch( @@ -75,17 +79,17 @@ def test_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance - + # Mock KubernetesDeploymentManager mock_k8s_manager = mocker.patch( "wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager" ) mock_k8s_instance = MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + # Mock configure_logging mocker.patch("chartreuse.chartreuse.configure_logging") - + chartreuse = Chartreuse( alembic_directory_path="/app/alembic", alembic_config_file_path="alembic.ini", @@ -93,12 +97,12 @@ def test_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) release_name="test-release", alembic_allow_migration_for_empty_database=False, ) - + # Verify KubernetesDeploymentManager was initialized mock_k8s_manager.assert_called_once_with( use_kubeconfig=None, release_name="test-release" ) - + assert chartreuse.kubernetes_helper == mock_k8s_instance assert chartreuse.is_migration_needed is False @@ -110,10 +114,10 @@ def test_check_migration_needed(self, mocker: MockerFixture) -> None: mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = True mock_alembic_helper.return_value = mock_alembic_instance - + mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - + chartreuse = Chartreuse( alembic_directory_path="/app/alembic", alembic_config_file_path="alembic.ini", @@ -122,10 +126,10 @@ def test_check_migration_needed(self, mocker: MockerFixture) -> None: alembic_allow_migration_for_empty_database=True, kubernetes_helper=mock_k8s_helper, ) - + result = chartreuse.check_migration_needed() assert result is True - + # Change the mock return value mock_alembic_instance.is_migration_needed = False result = chartreuse.check_migration_needed() @@ -139,10 +143,10 @@ def test_upgrade_when_migration_needed(self, mocker: MockerFixture) -> None: mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = True mock_alembic_helper.return_value = mock_alembic_instance - + mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - + chartreuse = Chartreuse( alembic_directory_path="/app/alembic", alembic_config_file_path="alembic.ini", @@ -151,9 +155,9 @@ def test_upgrade_when_migration_needed(self, mocker: MockerFixture) -> None: alembic_allow_migration_for_empty_database=True, kubernetes_helper=mock_k8s_helper, ) - + chartreuse.upgrade() - + # Verify upgrade_db was called mock_alembic_instance.upgrade_db.assert_called_once() @@ -165,10 +169,10 @@ def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance - + mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - + chartreuse = Chartreuse( alembic_directory_path="/app/alembic", alembic_config_file_path="alembic.ini", @@ -177,9 +181,9 @@ def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: alembic_allow_migration_for_empty_database=True, kubernetes_helper=mock_k8s_helper, ) - + chartreuse.upgrade() - + # Verify upgrade_db was not called mock_alembic_instance.upgrade_db.assert_not_called() @@ -187,30 +191,32 @@ def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: class TestMultiChartreuse: """Test cases for MultiChartreuse class.""" - def test_multi_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> None: + def test_multi_chartreuse_init_with_kubernetes_helper( + self, mocker: MockerFixture + ) -> None: """Test MultiChartreuse initialization with provided kubernetes helper.""" # Mock AlembicMigrationHelper mock_alembic_helper = mocker.patch( "chartreuse.chartreuse.AlembicMigrationHelper" ) - + # Create mock instances for different databases mock_main_instance = MagicMock() mock_main_instance.is_migration_needed = True - + mock_sec_instance = MagicMock() mock_sec_instance.is_migration_needed = False - + # Configure the mock to return different instances for different calls mock_alembic_helper.side_effect = [mock_main_instance, mock_sec_instance] - + # Mock configure_logging mocker.patch("chartreuse.chartreuse.configure_logging") - + # Create a mock kubernetes helper mock_k8s_helper = MagicMock() - - databases_config = [ + + databases_config: list[dict[str, any]] = [ { "name": "main", "alembic_directory_path": "/app/alembic/main", @@ -226,29 +232,31 @@ def test_multi_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixtur "url": "postgresql://user:pass@localhost:5433/secdb", }, ] - + multi_chartreuse = MultiChartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, ) - + # Verify AlembicMigrationHelper was called for each database assert mock_alembic_helper.call_count == 2 - + # Verify migration helpers are stored correctly assert "main" in multi_chartreuse.migration_helpers assert "secondary" in multi_chartreuse.migration_helpers assert multi_chartreuse.migration_helpers["main"] == mock_main_instance assert multi_chartreuse.migration_helpers["secondary"] == mock_sec_instance - + # Verify kubernetes helper is set assert multi_chartreuse.kubernetes_helper == mock_k8s_helper - + # Verify migration is needed (because main db needs migration) assert multi_chartreuse.is_migration_needed is True - def test_multi_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) -> None: + def test_multi_chartreuse_init_without_kubernetes_helper( + self, mocker: MockerFixture + ) -> None: """Test MultiChartreuse initialization without provided kubernetes helper.""" # Mock AlembicMigrationHelper mock_alembic_helper = mocker.patch( @@ -257,18 +265,18 @@ def test_multi_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFix mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance - + # Mock KubernetesDeploymentManager mock_k8s_manager = mocker.patch( "wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager" ) mock_k8s_instance = MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + # Mock configure_logging mocker.patch("chartreuse.chartreuse.configure_logging") - - databases_config = [ + + databases_config: list[dict[str, any]] = [ { "name": "test", "alembic_directory_path": "/app/alembic", @@ -276,38 +284,40 @@ def test_multi_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFix "url": "postgresql://user:pass@localhost:5432/db", } ] - + multi_chartreuse = MultiChartreuse( databases_config=databases_config, release_name="test-release", ) - + # Verify KubernetesDeploymentManager was initialized mock_k8s_manager.assert_called_once_with( use_kubeconfig=None, release_name="test-release" ) - + assert multi_chartreuse.kubernetes_helper == mock_k8s_instance - def test_check_migration_needed_with_mixed_needs(self, mocker: MockerFixture) -> None: + def test_check_migration_needed_with_mixed_needs( + self, mocker: MockerFixture + ) -> None: """Test check_migration_needed with some databases needing migration.""" mock_alembic_helper = mocker.patch( "chartreuse.chartreuse.AlembicMigrationHelper" ) - + # Create mock instances mock_main_instance = MagicMock() mock_main_instance.is_migration_needed = False - + mock_sec_instance = MagicMock() mock_sec_instance.is_migration_needed = True - + mock_alembic_helper.side_effect = [mock_main_instance, mock_sec_instance] - + mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - - databases_config = [ + + databases_config: list[dict[str, any]] = [ { "name": "main", "alembic_directory_path": "/app/alembic/main", @@ -321,31 +331,33 @@ def test_check_migration_needed_with_mixed_needs(self, mocker: MockerFixture) -> "url": "postgresql://user:pass@localhost:5433/secdb", }, ] - + multi_chartreuse = MultiChartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, ) - + # Should return True because secondary needs migration result = multi_chartreuse.check_migration_needed() assert result is True - def test_check_migration_needed_none_need_migration(self, mocker: MockerFixture) -> None: + def test_check_migration_needed_none_need_migration( + self, mocker: MockerFixture + ) -> None: """Test check_migration_needed when no databases need migration.""" mock_alembic_helper = mocker.patch( "chartreuse.chartreuse.AlembicMigrationHelper" ) - + mock_instance = MagicMock() mock_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_instance - + mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - - databases_config = [ + + databases_config: list[dict[str, any]] = [ { "name": "test", "alembic_directory_path": "/app/alembic", @@ -353,13 +365,13 @@ def test_check_migration_needed_none_need_migration(self, mocker: MockerFixture) "url": "postgresql://user:pass@localhost:5432/db", } ] - + multi_chartreuse = MultiChartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, ) - + result = multi_chartreuse.check_migration_needed() assert result is False @@ -368,20 +380,20 @@ def test_upgrade_only_needed_databases(self, mocker: MockerFixture) -> None: mock_alembic_helper = mocker.patch( "chartreuse.chartreuse.AlembicMigrationHelper" ) - + # Create mock instances mock_main_instance = MagicMock() mock_main_instance.is_migration_needed = True - + mock_sec_instance = MagicMock() mock_sec_instance.is_migration_needed = False - + mock_alembic_helper.side_effect = [mock_main_instance, mock_sec_instance] - + mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - - databases_config = [ + + databases_config: list[dict[str, any]] = [ { "name": "main", "alembic_directory_path": "/app/alembic/main", @@ -395,15 +407,15 @@ def test_upgrade_only_needed_databases(self, mocker: MockerFixture) -> None: "url": "postgresql://user:pass@localhost:5433/secdb", }, ] - + multi_chartreuse = MultiChartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, ) - + multi_chartreuse.upgrade() - + # Verify only main database was upgraded mock_main_instance.upgrade_db.assert_called_once() mock_sec_instance.upgrade_db.assert_not_called() @@ -416,10 +428,10 @@ def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance - + mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - + databases_config = [ { "name": "test", @@ -429,13 +441,13 @@ def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: # No allow_migration_for_empty_database or additional_parameters } ] - + MultiChartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, ) - + # Verify AlembicMigrationHelper was called with defaults mock_alembic_helper.assert_called_once_with( alembic_directory_path="/app/alembic", @@ -443,4 +455,4 @@ def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: database_url="postgresql://user:pass@localhost:5432/db", allow_migration_for_empty_database=False, # Default value additional_parameters="", # Default value - ) \ No newline at end of file + ) diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py index 42c9ddb..fb94b53 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py @@ -15,7 +15,7 @@ def test_ensure_safe_run_matching_versions(self, mocker: MockerFixture) -> None: """Test ensure_safe_run with matching major.minor versions.""" mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.2.1") mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0"}) - + # Should not raise any exception ensure_safe_run() @@ -23,10 +23,10 @@ def test_ensure_safe_run_mismatched_versions(self, mocker: MockerFixture) -> Non """Test ensure_safe_run with mismatched major.minor versions.""" mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.1.3") mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0"}) - + with pytest.raises(ValueError) as exc_info: ensure_safe_run() - + assert "don't have the same 'major.minor'" in str(exc_info.value) assert "5.1.3" in str(exc_info.value) assert "5.2.0" in str(exc_info.value) @@ -35,37 +35,43 @@ def test_ensure_safe_run_missing_helm_version(self, mocker: MockerFixture) -> No """Test ensure_safe_run with missing HELM_CHART_VERSION environment variable.""" mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.1.3") mocker.patch.dict(os.environ, {}, clear=True) - + with pytest.raises(ValueError) as exc_info: ensure_safe_run() - + assert "Couldn't get the Chartreuse's Helm Chart version" in str(exc_info.value) def test_ensure_safe_run_empty_helm_version(self, mocker: MockerFixture) -> None: """Test ensure_safe_run with empty HELM_CHART_VERSION environment variable.""" mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.1.3") mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": ""}) - + with pytest.raises(ValueError) as exc_info: ensure_safe_run() - + assert "Couldn't get the Chartreuse's Helm Chart version" in str(exc_info.value) - def test_ensure_safe_run_different_major_versions(self, mocker: MockerFixture) -> None: + def test_ensure_safe_run_different_major_versions( + self, mocker: MockerFixture + ) -> None: """Test ensure_safe_run with different major versions.""" mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="4.2.1") mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0"}) - + with pytest.raises(ValueError) as exc_info: ensure_safe_run() - + assert "(['5', '2'] != ['4', '2'])" in str(exc_info.value) - def test_ensure_safe_run_complex_version_formats(self, mocker: MockerFixture) -> None: + def test_ensure_safe_run_complex_version_formats( + self, mocker: MockerFixture + ) -> None: """Test ensure_safe_run with complex version formats.""" - mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.2.1-alpha.1") + mocker.patch( + "chartreuse.chartreuse_upgrade.get_version", return_value="5.2.1-alpha.1" + ) mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0-beta.2"}) - + # Should not raise any exception as major.minor match ensure_safe_run() @@ -77,7 +83,7 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: """Test main function with successful multi-database configuration.""" # Mock ensure_safe_run mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + # Mock config loading mock_config = [ { @@ -91,7 +97,7 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: "chartreuse.chartreuse_upgrade.load_multi_database_config", return_value=mock_config, ) - + # Mock MultiChartreuse mock_multi_chartreuse = mocker.patch( "chartreuse.chartreuse_upgrade.MultiChartreuse" @@ -99,14 +105,14 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance - + # Mock KubernetesDeploymentManager mock_k8s_manager = mocker.patch( "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" ) mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + # Set up environment mocker.patch.dict( os.environ, @@ -118,9 +124,9 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: "HELM_IS_INSTALL": "false", }, ) - + main() - + # Verify mocks were called correctly mock_load_config.assert_called_once_with("/app/config.yaml") mock_multi_chartreuse.assert_called_once() @@ -128,10 +134,12 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: mock_k8s_instance.stop_pods.assert_called_once() mock_k8s_instance.start_pods.assert_called_once() - def test_main_multi_database_no_migration_needed(self, mocker: MockerFixture) -> None: + def test_main_multi_database_no_migration_needed( + self, mocker: MockerFixture + ) -> None: """Test main function when no migration is needed.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mock_config = [ { "name": "main", @@ -144,20 +152,20 @@ def test_main_multi_database_no_migration_needed(self, mocker: MockerFixture) -> "chartreuse.chartreuse_upgrade.load_multi_database_config", return_value=mock_config, ) - + mock_multi_chartreuse = mocker.patch( "chartreuse.chartreuse_upgrade.MultiChartreuse" ) mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = False mock_multi_chartreuse.return_value = mock_chartreuse_instance - + mock_k8s_manager = mocker.patch( "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" ) mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + mocker.patch.dict( os.environ, { @@ -168,23 +176,25 @@ def test_main_multi_database_no_migration_needed(self, mocker: MockerFixture) -> "HELM_IS_INSTALL": "false", }, ) - + main() - + # Verify no pods were stopped/started and no upgrade occurred mock_chartreuse_instance.upgrade.assert_not_called() mock_k8s_instance.stop_pods.assert_not_called() mock_k8s_instance.start_pods.assert_not_called() - def test_main_multi_database_config_load_failure(self, mocker: MockerFixture) -> None: + def test_main_multi_database_config_load_failure( + self, mocker: MockerFixture + ) -> None: """Test main function with config loading failure.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mocker.patch( "chartreuse.chartreuse_upgrade.load_multi_database_config", side_effect=FileNotFoundError("Config file not found"), ) - + mocker.patch.dict( os.environ, { @@ -192,14 +202,16 @@ def test_main_multi_database_config_load_failure(self, mocker: MockerFixture) -> "CHARTREUSE_RELEASE_NAME": "test-release", }, ) - + with pytest.raises(FileNotFoundError): main() - def test_main_multi_database_stop_pods_disabled(self, mocker: MockerFixture) -> None: + def test_main_multi_database_stop_pods_disabled( + self, mocker: MockerFixture + ) -> None: """Test main function with ENABLE_STOP_PODS disabled.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mock_config = [ { "name": "main", @@ -212,20 +224,20 @@ def test_main_multi_database_stop_pods_disabled(self, mocker: MockerFixture) -> "chartreuse.chartreuse_upgrade.load_multi_database_config", return_value=mock_config, ) - + mock_multi_chartreuse = mocker.patch( "chartreuse.chartreuse_upgrade.MultiChartreuse" ) mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance - + mock_k8s_manager = mocker.patch( "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" ) mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + mocker.patch.dict( os.environ, { @@ -236,18 +248,20 @@ def test_main_multi_database_stop_pods_disabled(self, mocker: MockerFixture) -> "HELM_IS_INSTALL": "false", }, ) - + main() - + # Verify upgrade was called but pods were not stopped/started mock_chartreuse_instance.upgrade.assert_called_once() mock_k8s_instance.stop_pods.assert_not_called() mock_k8s_instance.start_pods.assert_not_called() - def test_main_multi_database_upgrade_before_deployment(self, mocker: MockerFixture) -> None: + def test_main_multi_database_upgrade_before_deployment( + self, mocker: MockerFixture + ) -> None: """Test main function with UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mock_config = [ { "name": "main", @@ -260,20 +274,20 @@ def test_main_multi_database_upgrade_before_deployment(self, mocker: MockerFixtu "chartreuse.chartreuse_upgrade.load_multi_database_config", return_value=mock_config, ) - + mock_multi_chartreuse = mocker.patch( "chartreuse.chartreuse_upgrade.MultiChartreuse" ) mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance - + mock_k8s_manager = mocker.patch( "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" ) mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + mocker.patch.dict( os.environ, { @@ -284,18 +298,20 @@ def test_main_multi_database_upgrade_before_deployment(self, mocker: MockerFixtu "HELM_IS_INSTALL": "false", }, ) - + main() - + # Verify pods were stopped and upgrade was called, but start_pods was not called mock_chartreuse_instance.upgrade.assert_called_once() mock_k8s_instance.stop_pods.assert_called_once() mock_k8s_instance.start_pods.assert_not_called() - def test_main_multi_database_start_pods_failure(self, mocker: MockerFixture) -> None: + def test_main_multi_database_start_pods_failure( + self, mocker: MockerFixture + ) -> None: """Test main function when start_pods fails.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mock_config = [ { "name": "main", @@ -308,24 +324,24 @@ def test_main_multi_database_start_pods_failure(self, mocker: MockerFixture) -> "chartreuse.chartreuse_upgrade.load_multi_database_config", return_value=mock_config, ) - + mock_multi_chartreuse = mocker.patch( "chartreuse.chartreuse_upgrade.MultiChartreuse" ) mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance - + mock_k8s_manager = mocker.patch( "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" ) mock_k8s_instance = mocker.MagicMock() mock_k8s_instance.start_pods.side_effect = Exception("Failed to start pods") mock_k8s_manager.return_value = mock_k8s_instance - + # Mock logger to verify error was logged mock_logger = mocker.patch("chartreuse.chartreuse_upgrade.logger") - + mocker.patch.dict( os.environ, { @@ -336,10 +352,10 @@ def test_main_multi_database_start_pods_failure(self, mocker: MockerFixture) -> "HELM_IS_INSTALL": "false", }, ) - + # Should not raise exception, just log error main() - + # Verify error was logged mock_logger.error.assert_called_once() assert "Couldn't scale up new pods" in str(mock_logger.error.call_args) @@ -351,18 +367,18 @@ class TestMainSingleDatabase: def test_main_single_database_success(self, mocker: MockerFixture) -> None: """Test main function with successful single-database configuration.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_chartreuse.return_value = mock_chartreuse_instance - + mock_k8s_manager = mocker.patch( "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" ) mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + # Set up environment for single-database mode (no CHARTREUSE_MULTI_CONFIG_PATH) mocker.patch.dict( os.environ, @@ -379,9 +395,9 @@ def test_main_single_database_success(self, mocker: MockerFixture) -> None: }, clear=True, ) - + main() - + # Verify Chartreuse was initialized correctly mock_chartreuse.assert_called_once_with( alembic_directory_path="/app/alembic", @@ -392,7 +408,7 @@ def test_main_single_database_success(self, mocker: MockerFixture) -> None: release_name="test-release", kubernetes_helper=mock_k8s_instance, ) - + # Verify upgrade flow mock_chartreuse_instance.upgrade.assert_called_once() mock_k8s_instance.stop_pods.assert_called_once() @@ -401,18 +417,18 @@ def test_main_single_database_success(self, mocker: MockerFixture) -> None: def test_main_single_database_default_values(self, mocker: MockerFixture) -> None: """Test main function with default values for optional environment variables.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = False mock_chartreuse.return_value = mock_chartreuse_instance - + mock_k8s_manager = mocker.patch( "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" ) mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - + # Set minimal required environment variables mocker.patch.dict( os.environ, @@ -427,9 +443,9 @@ def test_main_single_database_default_values(self, mocker: MockerFixture) -> Non }, clear=True, ) - + main() - + # Verify Chartreuse was initialized with default values # Note: The boolean parsing in single-database mode uses bool(env_var) # which means any non-empty string is True @@ -442,7 +458,7 @@ def test_main_single_database_default_values(self, mocker: MockerFixture) -> Non release_name="test-release", kubernetes_helper=mock_k8s_instance, ) - + # No migration needed, so no pods operations mock_chartreuse_instance.upgrade.assert_not_called() mock_k8s_instance.stop_pods.assert_not_called() @@ -461,9 +477,9 @@ class TestMainBooleanParsing: ("false", True), # bool("false") -> True (non-empty string) ("FALSE", True), # bool("FALSE") -> True (non-empty string) ("False", True), # bool("False") -> True (non-empty string) - ("", False), # bool("") -> False (empty string) - ("0", True), # bool("0") -> True (non-empty string) - ("1", True), # bool("1") -> True (non-empty string) + ("", False), # bool("") -> False (empty string) + ("0", True), # bool("0") -> True (non-empty string) + ("1", True), # bool("1") -> True (non-empty string) ], ) def test_boolean_parsing_variations( @@ -471,16 +487,14 @@ def test_boolean_parsing_variations( ) -> None: """Test various boolean string parsing in environment variables.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") - + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = False mock_chartreuse.return_value = mock_chartreuse_instance - - mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) - + + mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") + # Set up environment for single-database mode (no CHARTREUSE_MULTI_CONFIG_PATH) # Note: In single-database mode, boolean parsing uses bool(env_var) # which means any non-empty string is True, empty string is False @@ -497,9 +511,9 @@ def test_boolean_parsing_variations( }, clear=True, ) - + main() - + # Check that the boolean was parsed correctly call_args = mock_chartreuse.call_args[1] - assert call_args["alembic_allow_migration_for_empty_database"] == expected \ No newline at end of file + assert call_args["alembic_allow_migration_for_empty_database"] == expected diff --git a/src/chartreuse/tests/unit_tests/test_config_loader.py b/src/chartreuse/tests/unit_tests/test_config_loader.py index 77f3853..51ff691 100644 --- a/src/chartreuse/tests/unit_tests/test_config_loader.py +++ b/src/chartreuse/tests/unit_tests/test_config_loader.py @@ -22,10 +22,10 @@ def test_build_database_url_success(self) -> None: "port": "5432", "database": "testdb", } - + expected_url = "postgresql://testuser:testpass@localhost:5432/testdb" result = build_database_url(db_config) - + assert result == expected_url def test_build_database_url_missing_components(self) -> None: @@ -35,10 +35,10 @@ def test_build_database_url_missing_components(self) -> None: "user": "testuser", # Missing password, host, port, database } - + with pytest.raises(ValueError) as exc_info: build_database_url(db_config) - + assert "Missing required components" in str(exc_info.value) assert "password" in str(exc_info.value) assert "host" in str(exc_info.value) @@ -55,10 +55,10 @@ def test_build_database_url_empty_components(self) -> None: "port": "5432", "database": "testdb", } - + with pytest.raises(ValueError) as exc_info: build_database_url(db_config) - + assert "Missing required components" in str(exc_info.value) assert "user" in str(exc_info.value) @@ -72,10 +72,10 @@ def test_build_database_url_with_special_characters(self) -> None: "port": "5432", "database": "test-db", } - + expected_url = "postgresql://test@user:test!@#$%pass@localhost:5432/test-db" result = build_database_url(db_config) - + assert result == expected_url @@ -112,66 +112,63 @@ def test_load_multi_database_config_success(self) -> None: } } - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(config_data, f) temp_file = f.name try: result = load_multi_database_config(temp_file) - + assert len(result) == 2 - + # Check main database config main_db = next(db for db in result if db["name"] == "main") assert main_db["alembic_directory_path"] == "/app/alembic/main" - assert main_db["url"] == "postgresql://mainuser:mainpass@main.db.com:5432/maindb" + assert ( + main_db["url"] + == "postgresql://mainuser:mainpass@main.db.com:5432/maindb" + ) assert main_db["allow_migration_for_empty_database"] is True assert main_db["additional_parameters"] == "--verbose" - + # Check secondary database config sec_db = next(db for db in result if db["name"] == "secondary") assert sec_db["alembic_directory_path"] == "/app/alembic/secondary" assert sec_db["url"] == "postgresql://secuser:secpass@sec.db.com:5433/secdb" assert sec_db["allow_migration_for_empty_database"] is False assert sec_db.get("additional_parameters", "") == "" - + finally: os.unlink(temp_file) def test_load_multi_database_config_missing_databases_key(self) -> None: """Test that missing 'databases' key raises ValueError.""" - config_data = { - "some_other_key": { - "value": "test" - } - } + config_data = {"some_other_key": {"value": "test"}} - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(config_data, f) temp_file = f.name try: with pytest.raises(ValueError) as exc_info: load_multi_database_config(temp_file) - + assert "Configuration must contain 'databases' key" in str(exc_info.value) finally: os.unlink(temp_file) def test_load_multi_database_config_databases_not_dict(self) -> None: """Test that non-dict 'databases' value raises ValueError.""" - config_data = { - "databases": ["not", "a", "dict"] - } + config_data = {"databases": ["not", "a", "dict"]} - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(config_data, f) temp_file = f.name try: with pytest.raises(ValueError) as exc_info: load_multi_database_config(temp_file) - + assert "'databases' must be a dictionary" in str(exc_info.value) finally: os.unlink(temp_file) @@ -189,14 +186,14 @@ def test_load_multi_database_config_missing_required_components(self) -> None: } } - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(config_data, f) temp_file = f.name try: with pytest.raises(ValueError) as exc_info: load_multi_database_config(temp_file) - + assert "Missing required components" in str(exc_info.value) finally: os.unlink(temp_file) @@ -204,11 +201,13 @@ def test_load_multi_database_config_missing_required_components(self) -> None: def test_load_multi_database_config_file_not_found(self) -> None: """Test that missing config file raises FileNotFoundError.""" non_existent_file = "/path/that/does/not/exist.yaml" - + with pytest.raises(FileNotFoundError) as exc_info: load_multi_database_config(non_existent_file) - - assert f"Configuration file not found: {non_existent_file}" in str(exc_info.value) + + assert f"Configuration file not found: {non_existent_file}" in str( + exc_info.value + ) def test_load_multi_database_config_invalid_yaml(self) -> None: """Test that invalid YAML raises ValueError.""" @@ -219,14 +218,14 @@ def test_load_multi_database_config_invalid_yaml(self) -> None: invalid: [unclosed bracket """ - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(invalid_yaml) temp_file = f.name try: with pytest.raises(ValueError) as exc_info: load_multi_database_config(temp_file) - + assert "Invalid YAML in configuration file" in str(exc_info.value) finally: os.unlink(temp_file) @@ -248,19 +247,19 @@ def test_load_multi_database_config_minimal_config(self) -> None: } } - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: yaml.dump(config_data, f) temp_file = f.name try: result = load_multi_database_config(temp_file) - + assert len(result) == 1 db_config = result[0] assert db_config["name"] == "minimal" assert db_config["url"] == "postgresql://user:pass@localhost:5432/db" assert db_config.get("allow_migration_for_empty_database", False) is False assert db_config.get("additional_parameters", "") == "" - + finally: - os.unlink(temp_file) \ No newline at end of file + os.unlink(temp_file) From 00c57b17113ef81c6b880dede4ee5627e21aa068 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:39:18 +0200 Subject: [PATCH 09/13] feat(deps): implement ruff --- .github/workflows/python-test.yml | 15 +--- CHANGELOG.md | 2 +- VERSION | 2 +- example/alembic/env.py | 7 +- pyproject.toml | 46 +++++----- scripts/validate_config.py | 16 +--- setup.py | 4 +- src/chartreuse/chartreuse.py | 27 ++---- src/chartreuse/chartreuse_upgrade.py | 35 ++------ src/chartreuse/config_loader.py | 23 ++--- src/chartreuse/tests/conftest.py | 26 +++--- src/chartreuse/tests/e2e_tests/conftest.py | 10 +-- src/chartreuse/tests/unit_tests/conftest.py | 4 +- .../tests/unit_tests/test_alembic.py | 48 +++-------- .../tests/unit_tests/test_chartreuse.py | 84 +++++-------------- .../unit_tests/test_chartreuse_upgrade.py | 38 ++------- .../test_chartreuse_upgrade_extended.py | 84 +++++-------------- .../tests/unit_tests/test_config_loader.py | 9 +- .../utils/alembic_migration_helper.py | 34 ++------ 19 files changed, 153 insertions(+), 361 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index f824e03..d450fa9 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -34,17 +34,10 @@ jobs: python -m pip install -r requirements.txt python -m pip install -e .[dev] - - name: Lint with flake8 + - name: Lint and check with ruff run: | - flake8 . --count --show-source --statistics - - - name: Check typing with mypy - run: | - mypy --install-types --non-interactive . - - - name: Check syntax with pyupgrade - run: | - find . -type f -regex '.*\.py$' -exec pyupgrade --py311-plus {} \; + ruff check . --output-format=github + ruff format --check . - name: Set up Helm uses: azure/setup-helm@v1 @@ -76,4 +69,4 @@ jobs: with: github_token: ${{ secrets.github_token }} # Change reviewdog reporter if you need [github-pr-check, github-check]. - reporter: github-pr-check + reporter: github-pr-check \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c84256..c69656f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v5.1.0 (2025-10-07) +## v6.0.0 (2025-10-07) ### Feat - add support for multi-database migration diff --git a/VERSION b/VERSION index 831446c..09b254e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.1.0 +6.0.0 diff --git a/example/alembic/env.py b/example/alembic/env.py index 5f5e670..0591c35 100644 --- a/example/alembic/env.py +++ b/example/alembic/env.py @@ -1,8 +1,7 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config, pool, text - from alembic import context +from sqlalchemy import engine_from_config, pool, text # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -55,9 +54,7 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - patroni_postgresql: bool = "patroni_postgresql" in context.get_x_argument( - as_dictionary=True - ) + patroni_postgresql: bool = "patroni_postgresql" in context.get_x_argument(as_dictionary=True) connectable = engine_from_config( config.get_section(config.config_ini_section), # type: ignore prefix="sqlalchemy.", diff --git a/pyproject.toml b/pyproject.toml index 0db4aec..4fcc0d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,41 +34,41 @@ test = [ "pytest", "pytest-mock", ] -mypy = [ - "mypy", +ruff = [ + "ruff", ] dev = [ - "black", - "flake8", - "flake8-mutable", + "ruff", "pip-tools", "mock", "pytest", "pytest-mock", - - "mypy" ] -[tool.black] +[tool.ruff] line-length = 120 -target-version = ['py311'] +target-version = "py311" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions +] +ignore = [] -[tool.isort] -line_length = 120 +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" -[tool.mypy] -python_version = "3.11" -ignore_missing_imports = true -check_untyped_defs = true -disallow_untyped_defs = true -disallow_incomplete_defs = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_unused_configs = true -no_implicit_optional = true -show_error_codes = true -files = [ "src", "example/alembic"] +[tool.ruff.lint.isort] +known-first-party = ["chartreuse"] [tool.pytest.ini_options] log_level = "INFO" diff --git a/scripts/validate_config.py b/scripts/validate_config.py index 0513768..828084d 100644 --- a/scripts/validate_config.py +++ b/scripts/validate_config.py @@ -14,11 +14,7 @@ def build_database_url(db_config: dict) -> str: """Build database URL from components.""" required_components = ["dialect", "user", "password", "host", "port", "database"] - missing_components = [ - comp - for comp in required_components - if comp not in db_config or not db_config[comp] - ] + missing_components = [comp for comp in required_components if comp not in db_config or not db_config[comp]] if missing_components: raise ValueError(f"Missing components: {missing_components}") @@ -43,7 +39,7 @@ def validate_config(config_path: str) -> bool: return False # Load and parse YAML - with open(config_path, "r") as f: + with open(config_path) as f: config = yaml.safe_load(f) # Validate structure @@ -101,12 +97,8 @@ def validate_config(config_path: str) -> bool: # Check optional boolean fields for bool_field in ["allow_migration_for_empty_database"]: - if bool_field in db_config and not isinstance( - db_config[bool_field], bool - ): - print( - f"❌ Database {db_name}: '{bool_field}' must be true or false" - ) + if bool_field in db_config and not isinstance(db_config[bool_field], bool): + print(f"❌ Database {db_name}: '{bool_field}' must be true or false") return False print(f"✅ Database {db_name}: OK") diff --git a/setup.py b/setup.py index a224c9f..33b2174 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,5 @@ "mypy": extra_require_mypy, "test": extra_require_test, }, - entry_points={ - "console_scripts": ["chartreuse-upgrade=chartreuse.chartreuse_upgrade:main"] - }, + entry_points={"console_scripts": ["chartreuse-upgrade=chartreuse.chartreuse_upgrade:main"]}, ) diff --git a/src/chartreuse/chartreuse.py b/src/chartreuse/chartreuse.py index 108b987..e2f474b 100644 --- a/src/chartreuse/chartreuse.py +++ b/src/chartreuse/chartreuse.py @@ -1,5 +1,4 @@ import logging -from typing import Dict, List import wiremind_kubernetes.kubernetes_helper @@ -25,8 +24,7 @@ def __init__( release_name: str, alembic_allow_migration_for_empty_database: bool, alembic_additional_parameters: str = "", - kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager - | None = None, + kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None, ): configure_logging() @@ -41,10 +39,8 @@ def __init__( if kubernetes_helper: self.kubernetes_helper = kubernetes_helper else: - self.kubernetes_helper = ( - wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( - use_kubeconfig=None, release_name=release_name - ) + self.kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( + use_kubeconfig=None, release_name=release_name ) self.is_migration_needed = self.check_migration_needed() @@ -62,15 +58,14 @@ class MultiChartreuse: def __init__( self, - databases_config: List[Dict], + databases_config: list[dict], release_name: str, - kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager - | None = None, + kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None, ): configure_logging() self.databases_config = databases_config - self.migration_helpers: Dict[str, AlembicMigrationHelper] = {} + self.migration_helpers: dict[str, AlembicMigrationHelper] = {} # Initialize migration helpers for each database for db_config in databases_config: @@ -81,9 +76,7 @@ def __init__( alembic_directory_path=db_config["alembic_directory_path"], alembic_config_file_path=db_config["alembic_config_file_path"], database_url=db_config["url"], - allow_migration_for_empty_database=db_config.get( - "allow_migration_for_empty_database", False - ), + allow_migration_for_empty_database=db_config.get("allow_migration_for_empty_database", False), additional_parameters=db_config.get("additional_parameters", ""), ) self.migration_helpers[db_name] = helper @@ -92,10 +85,8 @@ def __init__( if kubernetes_helper: self.kubernetes_helper = kubernetes_helper else: - self.kubernetes_helper = ( - wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( - use_kubeconfig=None, release_name=release_name - ) + self.kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( + use_kubeconfig=None, release_name=release_name ) # Check if any migrations are needed diff --git a/src/chartreuse/chartreuse_upgrade.py b/src/chartreuse/chartreuse_upgrade.py index 3cbc52d..fb22eb2 100755 --- a/src/chartreuse/chartreuse_upgrade.py +++ b/src/chartreuse/chartreuse_upgrade.py @@ -54,21 +54,14 @@ def main() -> None: logger.error(f"Failed to load multi-database configuration: {e}") raise - ENABLE_STOP_PODS: bool = bool( - os.environ.get("CHARTREUSE_ENABLE_STOP_PODS", "true").lower() == "true" - ) + ENABLE_STOP_PODS: bool = bool(os.environ.get("CHARTREUSE_ENABLE_STOP_PODS", "true").lower() == "true") RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] UPGRADE_BEFORE_DEPLOYMENT: bool = bool( - os.environ.get("CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT", "false").lower() - == "true" - ) - HELM_IS_INSTALL: bool = bool( - os.environ.get("HELM_IS_INSTALL", "false").lower() == "true" + os.environ.get("CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT", "false").lower() == "true" ) + HELM_IS_INSTALL: bool = bool(os.environ.get("HELM_IS_INSTALL", "false").lower() == "true") - deployment_manager = KubernetesDeploymentManager( - release_name=RELEASE_NAME, use_kubeconfig=None - ) + deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) chartreuse = MultiChartreuse( databases_config=databases_config, release_name=RELEASE_NAME, @@ -96,29 +89,19 @@ def main() -> None: # Use legacy single-database configuration logger.info("Using single-database configuration from environment variables") - ALEMBIC_DIRECTORY_PATH: str = os.environ.get( - "CHARTREUSE_ALEMBIC_DIRECTORY_PATH", "/app/alembic" - ) - ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get( - "CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini" - ) + ALEMBIC_DIRECTORY_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_DIRECTORY_PATH", "/app/alembic") + ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini") POSTGRESQL_URL: str | None = os.environ["CHARTREUSE_ALEMBIC_URL"] ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE: bool = bool( os.environ["CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE"] ) - ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ[ - "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS" - ] + ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ["CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS"] SINGLE_ENABLE_STOP_PODS: bool = bool(os.environ["CHARTREUSE_ENABLE_STOP_PODS"]) SINGLE_RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] - SINGLE_UPGRADE_BEFORE_DEPLOYMENT: bool = bool( - os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"] - ) + SINGLE_UPGRADE_BEFORE_DEPLOYMENT: bool = bool(os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"]) SINGLE_HELM_IS_INSTALL: bool = bool(os.environ["HELM_IS_INSTALL"]) - deployment_manager = KubernetesDeploymentManager( - release_name=SINGLE_RELEASE_NAME, use_kubeconfig=None - ) + deployment_manager = KubernetesDeploymentManager(release_name=SINGLE_RELEASE_NAME, use_kubeconfig=None) single_chartreuse = Chartreuse( alembic_directory_path=ALEMBIC_DIRECTORY_PATH, alembic_config_file_path=ALEMBIC_CONFIG_FILE_PATH, diff --git a/src/chartreuse/config_loader.py b/src/chartreuse/config_loader.py index 137dc49..c64768d 100644 --- a/src/chartreuse/config_loader.py +++ b/src/chartreuse/config_loader.py @@ -3,14 +3,13 @@ """ import logging -from typing import Dict, List import yaml logger = logging.getLogger(__name__) -def build_database_url(db_config: Dict) -> str: +def build_database_url(db_config: dict) -> str: """ Build database URL from individual components. @@ -18,11 +17,7 @@ def build_database_url(db_config: Dict) -> str: """ # Build URL from components required_components = ["dialect", "user", "password", "host", "port", "database"] - missing_components = [ - comp - for comp in required_components - if comp not in db_config or not db_config[comp] - ] + missing_components = [comp for comp in required_components if comp not in db_config or not db_config[comp]] if missing_components: raise ValueError(f"Missing required components: {missing_components}") @@ -37,7 +32,7 @@ def build_database_url(db_config: Dict) -> str: return f"{dialect}://{user}:{password}@{host}:{port}/{database}" -def load_multi_database_config(config_path: str) -> List[Dict]: +def load_multi_database_config(config_path: str) -> list[dict]: """ Load multi-database configuration from YAML file. @@ -56,7 +51,7 @@ def load_multi_database_config(config_path: str) -> List[Dict]: additional_parameters: "" """ try: - with open(config_path, "r") as f: + with open(config_path) as f: config = yaml.safe_load(f) if "databases" not in config: @@ -65,9 +60,7 @@ def load_multi_database_config(config_path: str) -> List[Dict]: databases_dict = config["databases"] if not isinstance(databases_dict, dict): - raise ValueError( - "'databases' must be a dictionary with database names as keys" - ) + raise ValueError("'databases' must be a dictionary with database names as keys") # Convert dictionary to list format for internal processing databases = [] @@ -82,7 +75,7 @@ def load_multi_database_config(config_path: str) -> List[Dict]: return databases - except FileNotFoundError: - raise FileNotFoundError(f"Configuration file not found: {config_path}") + except FileNotFoundError as err: + raise FileNotFoundError(f"Configuration file not found: {config_path}") from err except yaml.YAMLError as e: - raise ValueError(f"Invalid YAML in configuration file: {e}") + raise ValueError(f"Invalid YAML in configuration file: {e}") from e diff --git a/src/chartreuse/tests/conftest.py b/src/chartreuse/tests/conftest.py index 813c099..9cba59e 100644 --- a/src/chartreuse/tests/conftest.py +++ b/src/chartreuse/tests/conftest.py @@ -3,20 +3,18 @@ from pytest_mock.plugin import MockerFixture -def configure_os_environ_mock( - mocker: MockerFixture, additional_environment: dict[str, str] | None = None -) -> None: - new_environ: dict[str, str] = dict( - CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS="", - CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE="1", - CHARTREUSE_ALEMBIC_URL="foo", - CHARTREUSE_ENABLE_STOP_PODS="1", - CHARTREUSE_MIGRATE_IMAGE_PULL_SECRET="foo", - CHARTREUSE_RELEASE_NAME="foo", - RUN_TEST_IN_KIND=os.environ.get("RUN_TEST_IN_KIND", ""), - CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT="", - HELM_IS_INSTALL="", - ) +def configure_os_environ_mock(mocker: MockerFixture, additional_environment: dict[str, str] | None = None) -> None: + new_environ: dict[str, str] = { + "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS": "", + "CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE": "1", + "CHARTREUSE_ALEMBIC_URL": "foo", + "CHARTREUSE_ENABLE_STOP_PODS": "1", + "CHARTREUSE_MIGRATE_IMAGE_PULL_SECRET": "foo", + "CHARTREUSE_RELEASE_NAME": "foo", + "RUN_TEST_IN_KIND": os.environ.get("RUN_TEST_IN_KIND", ""), + "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "", + "HELM_IS_INSTALL": "", + } if additional_environment: new_environ.update(additional_environment) diff --git a/src/chartreuse/tests/e2e_tests/conftest.py b/src/chartreuse/tests/e2e_tests/conftest.py index 4697a0a..6bdccce 100644 --- a/src/chartreuse/tests/e2e_tests/conftest.py +++ b/src/chartreuse/tests/e2e_tests/conftest.py @@ -42,11 +42,11 @@ def _cluster_init(include_chartreuse: bool, pre_upgrade: bool = False) -> Genera else: additional_args = "--set chartreuse.enabled=true --set chartreuse.upgradeBeforeDeployment=false" run_command( - f"helm install --wait {TEST_RELEASE} {HELM_CHART_PATH} --namespace {TEST_NAMESPACE} --timeout 180s {additional_args}", + f"helm install --wait {TEST_RELEASE} {HELM_CHART_PATH} --namespace {TEST_NAMESPACE} --timeout 180s {additional_args}", # noqa: E501 cwd=EXAMPLE_PATH, ) run_command( - f"helm upgrade --wait {TEST_RELEASE} {HELM_CHART_PATH} --namespace {TEST_NAMESPACE} --timeout 60s {additional_args}", + f"helm upgrade --wait {TEST_RELEASE} {HELM_CHART_PATH} --namespace {TEST_NAMESPACE} --timeout 60s {additional_args}", # noqa: E501 cwd=EXAMPLE_PATH, ) @@ -63,7 +63,7 @@ def _cluster_init(include_chartreuse: bool, pre_upgrade: bool = False) -> Genera time.sleep(5) # Hack to wait for k exec to be up except: # noqa run_command( - f"kubectl logs --selector app.kubernetes.io/instance=e2e-test-release --all-containers=false --namespace {TEST_NAMESPACE} --tail 1000" + f"kubectl logs --selector app.kubernetes.io/instance=e2e-test-release --all-containers=false --namespace {TEST_NAMESPACE} --tail 1000" # noqa: E501 ) run_command(f"kubectl delete namespace {TEST_NAMESPACE} --grace-period=1") raise @@ -95,9 +95,7 @@ def prepare_container_image_and_helm_chart() -> None: cwd=HELM_CHART_PATH, ) - run_command( - "helm repo add wiremind https://wiremind.github.io/wiremind-helm-charts" - ) + run_command("helm repo add wiremind https://wiremind.github.io/wiremind-helm-charts") run_command( "helm install wiremind-crds wiremind/wiremind-crds --version 0.1.0", ) diff --git a/src/chartreuse/tests/unit_tests/conftest.py b/src/chartreuse/tests/unit_tests/conftest.py index 75891d9..5562b10 100644 --- a/src/chartreuse/tests/unit_tests/conftest.py +++ b/src/chartreuse/tests/unit_tests/conftest.py @@ -2,9 +2,7 @@ from pytest_mock.plugin import MockerFixture -def configure_chartreuse_mock( - mocker: MockerFixture, is_migration_needed: bool = True -) -> None: +def configure_chartreuse_mock(mocker: MockerFixture, is_migration_needed: bool = True) -> None: mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse.__init__", return_value=None) mocker.patch( "chartreuse.chartreuse_upgrade.Chartreuse.is_migration_needed", diff --git a/src/chartreuse/tests/unit_tests/test_alembic.py b/src/chartreuse/tests/unit_tests/test_alembic.py index 87ff329..344f416 100644 --- a/src/chartreuse/tests/unit_tests/test_alembic.py +++ b/src/chartreuse/tests/unit_tests/test_alembic.py @@ -12,9 +12,7 @@ def test_respect_empty_database(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False - ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) assert alembic_migration_helper.is_postgres_empty() @@ -31,9 +29,7 @@ def test_respect_not_empty_database(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value="123", ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False - ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) assert alembic_migration_helper.is_postgres_empty() is False @@ -57,9 +53,7 @@ def test_detect_needed_migration(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False - ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) assert alembic_migration_helper.is_migration_needed @@ -84,9 +78,7 @@ def test_detect_not_needed_migration(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False - ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) assert alembic_migration_helper.is_migration_needed is False @@ -106,9 +98,7 @@ def test_detect_needed_migration_non_existent(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False - ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) assert alembic_migration_helper.is_migration_needed @@ -129,9 +119,7 @@ def test_detect_database_is_empty(mocker: MockerFixture) -> None: return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False - ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) assert alembic_migration_helper.is_postgres_empty() @@ -152,9 +140,7 @@ def test_detect_database_is_not_empty(mocker: MockerFixture) -> None: return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False - ) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) assert alembic_migration_helper.is_postgres_empty() is False @@ -163,37 +149,27 @@ def test_additional_parameters(mocker: MockerFixture) -> None: """ Test that alembic additional parameters are respectedf for upgrade_db """ - mocker.patch( - "chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo" - ) + mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo") mocker.patch( "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value="bar", ) - mocked_run_command = mocker.patch( - "chartreuse.utils.alembic_migration_helper.run_command" - ) + mocked_run_command = mocker.patch("chartreuse.utils.alembic_migration_helper.run_command") alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( database_url="foo", configure=False, additional_parameters="foo bar" ) alembic_migration_helper.upgrade_db() - mocked_run_command.assert_called_with( - "alembic -c alembic.ini foo bar upgrade head", cwd="/app/alembic" - ) + mocked_run_command.assert_called_with("alembic -c alembic.ini foo bar upgrade head", cwd="/app/alembic") def test_additional_parameters_current(mocker: MockerFixture) -> None: """ Test that alembic additional parameters are respected in _get_alembic_current """ - mocker.patch( - "chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo" - ) - mocked_run_command = mocker.patch( - "chartreuse.utils.alembic_migration_helper.run_command" - ) + mocker.patch("chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value="foo") + mocked_run_command = mocker.patch("chartreuse.utils.alembic_migration_helper.run_command") mocked_run_command.return_value = ("bar", None, 0) alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse.py b/src/chartreuse/tests/unit_tests/test_chartreuse.py index 796c419..0053f22 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse.py @@ -27,14 +27,10 @@ def test_configure_logging(self, mocker: MockerFixture) -> None: class TestChartreuse: """Test cases for Chartreuse class.""" - def test_chartreuse_init_with_kubernetes_helper( - self, mocker: MockerFixture - ) -> None: + def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> None: """Test Chartreuse initialization with provided kubernetes helper.""" # Mock AlembicMigrationHelper - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = True mock_alembic_helper.return_value = mock_alembic_instance @@ -68,22 +64,16 @@ def test_chartreuse_init_with_kubernetes_helper( assert chartreuse.kubernetes_helper == mock_k8s_helper assert chartreuse.is_migration_needed is True - def test_chartreuse_init_without_kubernetes_helper( - self, mocker: MockerFixture - ) -> None: + def test_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) -> None: """Test Chartreuse initialization without provided kubernetes helper.""" # Mock AlembicMigrationHelper - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance # Mock KubernetesDeploymentManager - mock_k8s_manager = mocker.patch( - "wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager") mock_k8s_instance = MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -99,18 +89,14 @@ def test_chartreuse_init_without_kubernetes_helper( ) # Verify KubernetesDeploymentManager was initialized - mock_k8s_manager.assert_called_once_with( - use_kubeconfig=None, release_name="test-release" - ) + mock_k8s_manager.assert_called_once_with(use_kubeconfig=None, release_name="test-release") assert chartreuse.kubernetes_helper == mock_k8s_instance assert chartreuse.is_migration_needed is False def test_check_migration_needed(self, mocker: MockerFixture) -> None: """Test check_migration_needed method.""" - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = True mock_alembic_helper.return_value = mock_alembic_instance @@ -137,9 +123,7 @@ def test_check_migration_needed(self, mocker: MockerFixture) -> None: def test_upgrade_when_migration_needed(self, mocker: MockerFixture) -> None: """Test upgrade method when migration is needed.""" - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = True mock_alembic_helper.return_value = mock_alembic_instance @@ -163,9 +147,7 @@ def test_upgrade_when_migration_needed(self, mocker: MockerFixture) -> None: def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: """Test upgrade method when migration is not needed.""" - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance @@ -191,14 +173,10 @@ def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: class TestMultiChartreuse: """Test cases for MultiChartreuse class.""" - def test_multi_chartreuse_init_with_kubernetes_helper( - self, mocker: MockerFixture - ) -> None: + def test_multi_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> None: """Test MultiChartreuse initialization with provided kubernetes helper.""" # Mock AlembicMigrationHelper - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") # Create mock instances for different databases mock_main_instance = MagicMock() @@ -254,22 +232,16 @@ def test_multi_chartreuse_init_with_kubernetes_helper( # Verify migration is needed (because main db needs migration) assert multi_chartreuse.is_migration_needed is True - def test_multi_chartreuse_init_without_kubernetes_helper( - self, mocker: MockerFixture - ) -> None: + def test_multi_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) -> None: """Test MultiChartreuse initialization without provided kubernetes helper.""" # Mock AlembicMigrationHelper - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance # Mock KubernetesDeploymentManager - mock_k8s_manager = mocker.patch( - "wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager") mock_k8s_instance = MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -291,19 +263,13 @@ def test_multi_chartreuse_init_without_kubernetes_helper( ) # Verify KubernetesDeploymentManager was initialized - mock_k8s_manager.assert_called_once_with( - use_kubeconfig=None, release_name="test-release" - ) + mock_k8s_manager.assert_called_once_with(use_kubeconfig=None, release_name="test-release") assert multi_chartreuse.kubernetes_helper == mock_k8s_instance - def test_check_migration_needed_with_mixed_needs( - self, mocker: MockerFixture - ) -> None: + def test_check_migration_needed_with_mixed_needs(self, mocker: MockerFixture) -> None: """Test check_migration_needed with some databases needing migration.""" - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") # Create mock instances mock_main_instance = MagicMock() @@ -342,13 +308,9 @@ def test_check_migration_needed_with_mixed_needs( result = multi_chartreuse.check_migration_needed() assert result is True - def test_check_migration_needed_none_need_migration( - self, mocker: MockerFixture - ) -> None: + def test_check_migration_needed_none_need_migration(self, mocker: MockerFixture) -> None: """Test check_migration_needed when no databases need migration.""" - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_instance = MagicMock() mock_instance.is_migration_needed = False @@ -377,9 +339,7 @@ def test_check_migration_needed_none_need_migration( def test_upgrade_only_needed_databases(self, mocker: MockerFixture) -> None: """Test upgrade method only upgrades databases that need migration.""" - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") # Create mock instances mock_main_instance = MagicMock() @@ -422,9 +382,7 @@ def test_upgrade_only_needed_databases(self, mocker: MockerFixture) -> None: def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: """Test that default values are handled correctly for optional parameters.""" - mock_alembic_helper = mocker.patch( - "chartreuse.chartreuse.AlembicMigrationHelper" - ) + mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = False mock_alembic_helper.return_value = mock_alembic_instance diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py index 1f17068..2dcace9 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade.py @@ -14,14 +14,10 @@ def test_chartreuse_upgrade_detected_migration_enabled_stop_pods( Test that chartreuse_upgrades stop pods in case of detected migration. """ configure_chartreuse_mock(mocker=mocker, is_migration_needed=True) - mocked_stop_pods = mocker.patch( - "wiremind_kubernetes.KubernetesDeploymentManager.stop_pods" - ) + mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") - configure_os_environ_mock( - mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"} - ) + configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"}) chartreuse.chartreuse_upgrade.main() mocked_stop_pods.assert_called() @@ -34,16 +30,12 @@ def test_chartreuse_upgrade_detected_migration_disabled_stop_pods( Test that chartreuse_upgrades does not stop pods in case of detected migration but we disallow stop-pods. """ configure_chartreuse_mock(mocker=mocker, is_migration_needed=True) - mocked_stop_pods = mocker.patch( - "wiremind_kubernetes.KubernetesDeploymentManager.stop_pods" - ) + mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") configure_os_environ_mock( mocker=mocker, - additional_environment=dict( - CHARTREUSE_ENABLE_STOP_PODS="", HELM_CHART_VERSION="5.0.0" - ), + additional_environment={"CHARTREUSE_ENABLE_STOP_PODS": "", "HELM_CHART_VERSION": "5.0.0"}, ) chartreuse.chartreuse_upgrade.main() @@ -57,14 +49,10 @@ def test_chartreuse_upgrade_no_migration_disabled_stop_pods( Test that chartreuse_upgrades does NOT stop pods in case of migration not needed. """ configure_chartreuse_mock(mocker=mocker, is_migration_needed=False) - mocked_stop_pods = mocker.patch( - "wiremind_kubernetes.KubernetesDeploymentManager.stop_pods" - ) + mocked_stop_pods = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.stop_pods") mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager.start_pods") mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.0.0") - configure_os_environ_mock( - mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"} - ) + configure_os_environ_mock(mocker=mocker, additional_environment={"HELM_CHART_VERSION": "5.0.0"}) chartreuse.chartreuse_upgrade.main() mocked_stop_pods.assert_not_called() @@ -101,17 +89,9 @@ def test_chartreuse_upgrade_compatibility_check( Test that chartreuse_upgrade deals as expected with compatibility with the package version and the Helm Chart version. """ - mocker.patch( - "chartreuse.chartreuse_upgrade.get_version", return_value=package_version - ) - additional_environment = ( - {"HELM_CHART_VERSION": helm_chart_version} - if helm_chart_version is not None - else {} - ) - configure_os_environ_mock( - mocker=mocker, additional_environment=additional_environment - ) + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value=package_version) + additional_environment = {"HELM_CHART_VERSION": helm_chart_version} if helm_chart_version is not None else {} + configure_os_environ_mock(mocker=mocker, additional_environment=additional_environment) configure_chartreuse_mock(mocker=mocker, is_migration_needed=False) if should_raise: diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py index fb94b53..076ef2c 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py @@ -51,9 +51,7 @@ def test_ensure_safe_run_empty_helm_version(self, mocker: MockerFixture) -> None assert "Couldn't get the Chartreuse's Helm Chart version" in str(exc_info.value) - def test_ensure_safe_run_different_major_versions( - self, mocker: MockerFixture - ) -> None: + def test_ensure_safe_run_different_major_versions(self, mocker: MockerFixture) -> None: """Test ensure_safe_run with different major versions.""" mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="4.2.1") mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0"}) @@ -63,13 +61,9 @@ def test_ensure_safe_run_different_major_versions( assert "(['5', '2'] != ['4', '2'])" in str(exc_info.value) - def test_ensure_safe_run_complex_version_formats( - self, mocker: MockerFixture - ) -> None: + def test_ensure_safe_run_complex_version_formats(self, mocker: MockerFixture) -> None: """Test ensure_safe_run with complex version formats.""" - mocker.patch( - "chartreuse.chartreuse_upgrade.get_version", return_value="5.2.1-alpha.1" - ) + mocker.patch("chartreuse.chartreuse_upgrade.get_version", return_value="5.2.1-alpha.1") mocker.patch.dict(os.environ, {"HELM_CHART_VERSION": "5.2.0-beta.2"}) # Should not raise any exception as major.minor match @@ -99,17 +93,13 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: ) # Mock MultiChartreuse - mock_multi_chartreuse = mocker.patch( - "chartreuse.chartreuse_upgrade.MultiChartreuse" - ) + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance # Mock KubernetesDeploymentManager - mock_k8s_manager = mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -134,9 +124,7 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: mock_k8s_instance.stop_pods.assert_called_once() mock_k8s_instance.start_pods.assert_called_once() - def test_main_multi_database_no_migration_needed( - self, mocker: MockerFixture - ) -> None: + def test_main_multi_database_no_migration_needed(self, mocker: MockerFixture) -> None: """Test main function when no migration is needed.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") @@ -153,16 +141,12 @@ def test_main_multi_database_no_migration_needed( return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch( - "chartreuse.chartreuse_upgrade.MultiChartreuse" - ) + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = False mock_multi_chartreuse.return_value = mock_chartreuse_instance - mock_k8s_manager = mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -184,9 +168,7 @@ def test_main_multi_database_no_migration_needed( mock_k8s_instance.stop_pods.assert_not_called() mock_k8s_instance.start_pods.assert_not_called() - def test_main_multi_database_config_load_failure( - self, mocker: MockerFixture - ) -> None: + def test_main_multi_database_config_load_failure(self, mocker: MockerFixture) -> None: """Test main function with config loading failure.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") @@ -206,9 +188,7 @@ def test_main_multi_database_config_load_failure( with pytest.raises(FileNotFoundError): main() - def test_main_multi_database_stop_pods_disabled( - self, mocker: MockerFixture - ) -> None: + def test_main_multi_database_stop_pods_disabled(self, mocker: MockerFixture) -> None: """Test main function with ENABLE_STOP_PODS disabled.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") @@ -225,16 +205,12 @@ def test_main_multi_database_stop_pods_disabled( return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch( - "chartreuse.chartreuse_upgrade.MultiChartreuse" - ) + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance - mock_k8s_manager = mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -256,9 +232,7 @@ def test_main_multi_database_stop_pods_disabled( mock_k8s_instance.stop_pods.assert_not_called() mock_k8s_instance.start_pods.assert_not_called() - def test_main_multi_database_upgrade_before_deployment( - self, mocker: MockerFixture - ) -> None: + def test_main_multi_database_upgrade_before_deployment(self, mocker: MockerFixture) -> None: """Test main function with UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") @@ -275,16 +249,12 @@ def test_main_multi_database_upgrade_before_deployment( return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch( - "chartreuse.chartreuse_upgrade.MultiChartreuse" - ) + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance - mock_k8s_manager = mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -306,9 +276,7 @@ def test_main_multi_database_upgrade_before_deployment( mock_k8s_instance.stop_pods.assert_called_once() mock_k8s_instance.start_pods.assert_not_called() - def test_main_multi_database_start_pods_failure( - self, mocker: MockerFixture - ) -> None: + def test_main_multi_database_start_pods_failure(self, mocker: MockerFixture) -> None: """Test main function when start_pods fails.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") @@ -325,16 +293,12 @@ def test_main_multi_database_start_pods_failure( return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch( - "chartreuse.chartreuse_upgrade.MultiChartreuse" - ) + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance - mock_k8s_manager = mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") mock_k8s_instance = mocker.MagicMock() mock_k8s_instance.start_pods.side_effect = Exception("Failed to start pods") mock_k8s_manager.return_value = mock_k8s_instance @@ -373,9 +337,7 @@ def test_main_single_database_success(self, mocker: MockerFixture) -> None: mock_chartreuse_instance.is_migration_needed = True mock_chartreuse.return_value = mock_chartreuse_instance - mock_k8s_manager = mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -423,9 +385,7 @@ def test_main_single_database_default_values(self, mocker: MockerFixture) -> Non mock_chartreuse_instance.is_migration_needed = False mock_chartreuse.return_value = mock_chartreuse_instance - mock_k8s_manager = mocker.patch( - "chartreuse.chartreuse_upgrade.KubernetesDeploymentManager" - ) + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance @@ -482,9 +442,7 @@ class TestMainBooleanParsing: ("1", True), # bool("1") -> True (non-empty string) ], ) - def test_boolean_parsing_variations( - self, mocker: MockerFixture, bool_str: str, expected: bool - ) -> None: + def test_boolean_parsing_variations(self, mocker: MockerFixture, bool_str: str, expected: bool) -> None: """Test various boolean string parsing in environment variables.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") diff --git a/src/chartreuse/tests/unit_tests/test_config_loader.py b/src/chartreuse/tests/unit_tests/test_config_loader.py index 51ff691..4af9470 100644 --- a/src/chartreuse/tests/unit_tests/test_config_loader.py +++ b/src/chartreuse/tests/unit_tests/test_config_loader.py @@ -124,10 +124,7 @@ def test_load_multi_database_config_success(self) -> None: # Check main database config main_db = next(db for db in result if db["name"] == "main") assert main_db["alembic_directory_path"] == "/app/alembic/main" - assert ( - main_db["url"] - == "postgresql://mainuser:mainpass@main.db.com:5432/maindb" - ) + assert main_db["url"] == "postgresql://mainuser:mainpass@main.db.com:5432/maindb" assert main_db["allow_migration_for_empty_database"] is True assert main_db["additional_parameters"] == "--verbose" @@ -205,9 +202,7 @@ def test_load_multi_database_config_file_not_found(self) -> None: with pytest.raises(FileNotFoundError) as exc_info: load_multi_database_config(non_existent_file) - assert f"Configuration file not found: {non_existent_file}" in str( - exc_info.value - ) + assert f"Configuration file not found: {non_existent_file}" in str(exc_info.value) def test_load_multi_database_config_invalid_yaml(self) -> None: """Test that invalid YAML raises ValueError.""" diff --git a/src/chartreuse/utils/alembic_migration_helper.py b/src/chartreuse/utils/alembic_migration_helper.py index bf5e6d2..0759747 100755 --- a/src/chartreuse/utils/alembic_migration_helper.py +++ b/src/chartreuse/utils/alembic_migration_helper.py @@ -44,26 +44,20 @@ def __init__( self.is_migration_needed = self._check_migration_needed() def _configure(self) -> None: - with open( - f"{self.alembic_directory_path}/{self.alembic_config_file_path}" - ) as f: + with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}") as f: content = f.read() content_new = re.sub( "(sqlalchemy.url.*=.*){1}", - r"sqlalchemy.url=%s" % self.database_url, + rf"sqlalchemy.url={self.database_url}", content, flags=re.M, ) if content != content_new: - with open( - f"{self.alembic_directory_path}/{self.alembic_config_file_path}", "w" - ) as f: + with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}", "w") as f: f.write(content_new) logger.info("alembic.ini was configured.") else: - raise SubprocessError( - "configuration of alembic.ini has failed (alembic.ini is unchanged)" - ) + raise SubprocessError("configuration of alembic.ini has failed (alembic.ini is unchanged)") def _wait_postgres_is_configured(self) -> None: """ @@ -71,12 +65,8 @@ def _wait_postgres_is_configured(self) -> None: and that default privileges were configured. # TODO: Maybe make this a readinessProbe on Patroni PG Pods """ - wait_timeout = int( - os.getenv("CHARTREUSE_ALEMBIC_POSTGRES_WAIT_CONFIGURED_TIMEOUT", 60) - ) - engine = sqlalchemy.create_engine( - self.database_url, poolclass=NullPool, connect_args={"connect_timeout": 1} - ) + wait_timeout = int(os.getenv("CHARTREUSE_ALEMBIC_POSTGRES_WAIT_CONFIGURED_TIMEOUT", 60)) + engine = sqlalchemy.create_engine(self.database_url, poolclass=NullPool, connect_args={"connect_timeout": 1}) default_privileges_checks: list[str] = [ "SET ROLE wiremind_owner", # The real owner, alembic will switch to it before running migrations. @@ -94,9 +84,7 @@ def _wait_postgres_is_configured(self) -> None: with engine.connect() as connection: transac = connection.begin() # TODO: Use scalar_one() once sqlachemly >= 1.4 - _id = connection.execute( - text(";".join(default_privileges_checks)) - ).scalar() + _id = connection.execute(text(";".join(default_privileges_checks))).scalar() assert _id == 1 transac.rollback() logger.info( @@ -133,18 +121,14 @@ def is_postgres_empty(self) -> bool: def _get_alembic_current(self) -> str: command: str = f"alembic -c {self.alembic_config_file_path} {self.additional_parameters} current" - alembic_current, stderr, returncode = run_command( - command, return_result=True, cwd=self.alembic_directory_path - ) + alembic_current, stderr, returncode = run_command(command, return_result=True, cwd=self.alembic_directory_path) if returncode != 0: raise SubprocessError(f"{command} has failed: {alembic_current}, {stderr}") return alembic_current def _check_migration_needed(self) -> bool: if self.is_postgres_empty() and not self.allow_migration_for_empty_database: - logger.info( - "Database is not populated yet but migration for empty database is forbidden, not upgrading." - ) + logger.info("Database is not populated yet but migration for empty database is forbidden, not upgrading.") return False head_re = re.compile(r"^\w+ \(head\)$", re.MULTILINE) From aae398606b8bbb4888cd54071c6bf6d2905d9795 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:51:51 +0200 Subject: [PATCH 10/13] chore(deps): remove old lib building files --- .gitignore | 3 +++ setup.cfg | 34 -------------------------------- setup.py | 58 ------------------------------------------------------ 3 files changed, 3 insertions(+), 92 deletions(-) delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore index c250529..115cf1a 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ venv-python3.7 venv-python3.8 venv-python3.10 .python-version + +# For building local docker image +alembic diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index fcd8c6a..0000000 --- a/setup.cfg +++ /dev/null @@ -1,34 +0,0 @@ -[options] -python_requires = >= 3.11 - -[flake8] -max-line-length = 120 -# Enable flake8-mutable -enable-extensions = M511 -# W503 line break before binary operator. Black causes this error -# E203 whitespace before ':' -# E231 missing whitespace after ','. Black causes this error but seems to be right (see pep8) -# E501 line too long -ignore = W503, E203, E231, E501 -jobs = 4 - -[mypy] -python_version = 3.11 -ignore_missing_imports = True -check_untyped_defs = True -disallow_untyped_defs = True -disallow_incomplete_defs = True -warn_redundant_casts = True -warn_unused_ignores = True -warn_unused_configs = True -no_implicit_optional = True -show_error_codes = True -files = src,example/alembic - -[tool:pytest] -log_level=INFO -# Deterministic ordering for tests; useful for pytest-xdist. -env = - PYTHONHASHSEED=0 -filterwarnings = - ignore::pytest.PytestUnknownMarkWarning diff --git a/setup.py b/setup.py deleted file mode 100644 index 33b2174..0000000 --- a/setup.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Chartreuse -""" - -from setuptools import find_packages, setup - -with open("VERSION") as version_file: - version = version_file.read().strip() - - -extra_require_test = [ - "mock", - "pytest", - "pytest-mock", -] -extra_require_mypy = [ - "mypy", -] -extra_require_dev = ( - [ - "black", - "flake8", - "flake8-mutable", - "pip-tools", - ] - + extra_require_mypy - + extra_require_test -) - - -setup( - name="chartreuse", - version=version, - description="Helper for Alembic migrations within Kubernetes.", - long_description="Helper for Alembic migrations within Kubernetes.", - platforms=["posix"], - author="wiremind", - author_email="dev@wiremind.io", - url="https://github.com/wiremind/chartreuse", - license="LGPLv3+", - packages=find_packages("src", exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), - package_dir={"": "src"}, - include_package_data=True, - zip_safe=True, - python_requires=">=3.11.0", - install_requires=[ - "alembic", - "psycopg2", - "PyYAML", - "wiremind-kubernetes~=7.0", - ], - extras_require={ - "dev": extra_require_dev, - "mypy": extra_require_mypy, - "test": extra_require_test, - }, - entry_points={"console_scripts": ["chartreuse-upgrade=chartreuse.chartreuse_upgrade:main"]}, -) From 47adf56c91c6182c81a707b1f9875c37dfd88378 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:22:28 +0200 Subject: [PATCH 11/13] feat(test): add ClickHouse and PostgreSQL migration configurations and initial scripts --- .gitignore | 3 - example/Dockerfile-dev | 12 +- example/Dockerfile-dev-auth | 28 ++ example/alembic/alembic.ini | 64 +--- example/alembic/clickhouse/env.py | 100 +++++ example/alembic/clickhouse/script.py.mako | 28 ++ .../clickhouse/versions/20250606-inital.py | 344 ++++++++++++++++++ example/alembic/postgresl/README | 1 + example/alembic/{ => postgresl}/env.py | 0 .../alembic/{ => postgresl}/script.py.mako | 0 .../alembic/{ => postgresl}/versions/init.py | 0 pyproject.toml | 1 + src/chartreuse/chartreuse.py | 11 +- 13 files changed, 533 insertions(+), 59 deletions(-) create mode 100644 example/Dockerfile-dev-auth create mode 100644 example/alembic/clickhouse/env.py create mode 100644 example/alembic/clickhouse/script.py.mako create mode 100644 example/alembic/clickhouse/versions/20250606-inital.py create mode 100644 example/alembic/postgresl/README rename example/alembic/{ => postgresl}/env.py (100%) rename example/alembic/{ => postgresl}/script.py.mako (100%) rename example/alembic/{ => postgresl}/versions/init.py (100%) diff --git a/.gitignore b/.gitignore index 115cf1a..c250529 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,3 @@ venv-python3.7 venv-python3.8 venv-python3.10 .python-version - -# For building local docker image -alembic diff --git a/example/Dockerfile-dev b/example/Dockerfile-dev index efe32f5..588e753 100644 --- a/example/Dockerfile-dev +++ b/example/Dockerfile-dev @@ -1,12 +1,16 @@ # Only used for e2e tests. Do not use in production. - +# For Wiremind use only since it uses our private package wiremind-python FROM python:3.12 WORKDIR /app +# Install uv +RUN pip install uv + COPY requirements.txt requirements.txt -RUN pip install --no-cache-dir -r requirements.txt +RUN uv pip install --system --no-cache -r requirements.txt COPY . . -RUN pip install -e . -COPY example/alembic alembic +RUN uv pip install --system -e . +RUN uv pip install --system wiremind-python +COPY alembic alembic diff --git a/example/Dockerfile-dev-auth b/example/Dockerfile-dev-auth new file mode 100644 index 0000000..104de2f --- /dev/null +++ b/example/Dockerfile-dev-auth @@ -0,0 +1,28 @@ +# Only used for e2e tests. Do not use in production. +# For Wiremind use only since it uses our private package wiremind-python + +FROM python:3.12 + +WORKDIR /app + +# Install uv +RUN pip install uv + +# Install dependencies if requirements.txt exists +COPY pyproject.toml . +COPY VERSION . +COPY README.md . +COPY requirements.txt* ./ +COPY src/ src/ +COPY alembic/ alembic/ + +RUN if [ -f requirements.txt ]; then uv pip install --system --no-cache -r requirements.txt; fi + +# Install the package itself +RUN uv pip install --system -e . + +# Install wiremind-python with authentication using build secrets +# The secret will be mounted at build time but not stored in the image +RUN --mount=type=secret,id=uv_config,target=/root/.config/uv/uv.toml \ + mkdir -p /root/.config/uv && \ + uv pip install --system wiremind-python cyrillic clickhouse-sqlalchemy diff --git a/example/alembic/alembic.ini b/example/alembic/alembic.ini index a649ff9..b1d468a 100644 --- a/example/alembic/alembic.ini +++ b/example/alembic/alembic.ini @@ -1,55 +1,17 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = . - +[postgresql] +script_location = postgresl +sqlalchemy.url = postgresql://wiremind_owner@localhost:5432/wiremind +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s + +[clickhouse] +script_location = clickhouse +sqlalchemy.url = clickhouse://default@localhost:8123/wiremind +prepend_sys_path = .. # template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# timezone to use when rendering the date -# within the migration file as well as the filename. -# string value is passed to dateutil.tz.gettz() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the -# "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; this defaults -# to sample_alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path -# version_locations = %(here)s/bar %(here)s/bat sample_alembic/versions - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url=postgresql://foo:foo@localhost/foo?sslmode=prefer - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks=black -# black.type=console_scripts -# black.entrypoint=black -# black.options=-l 79 +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s -# Logging configuration +# Logging configuration - Required for fileConfig() in env.py [loggers] keys = root,sqlalchemy,alembic @@ -82,4 +44,4 @@ formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S +datefmt = %H:%M:%S \ No newline at end of file diff --git a/example/alembic/clickhouse/env.py b/example/alembic/clickhouse/env.py new file mode 100644 index 0000000..ce4fbb7 --- /dev/null +++ b/example/alembic/clickhouse/env.py @@ -0,0 +1,100 @@ +import logging + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# inspired by https://github.com/xzkostyan/clickhouse-sqlalchemy-alembic-example/blob/main/simple/migrations/env.py + +config = context.config +logger = logging.getLogger(__name__) + +logging.basicConfig(level=logging.INFO) +for key in list(logging.root.manager.loggerDict.keys()): + if "alembic" in key: + logging.getLogger(key).disabled = False + +from cyrillic.models import ClickhouseBase # noqa: E402 + +target_metadata = ClickhouseBase.metadata + +# Currently there is an issue in clickhouse-sqlalchemy https://github.com/xzkostyan/clickhouse-sqlalchemy/pull/369 +# makes Alembic mimic what clickhouse_sqlalchemy.alembic is expecting (designed for Alembic 1.5.8) +# https://github.com/sqlalchemy/alembic/blob/rel_1_5_8/alembic/util/sqla_compat.py#L180 +from alembic.util import sqla_compat # noqa: E402 + +sqla_compat._reflect_table = lambda inspector, table, include_cols: inspector.reflect_table(table, None) +# patch before next import +from clickhouse_sqlalchemy.alembic.dialect import include_object, patch_alembic_version # noqa: E402 + +REPLICATION = True + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + if REPLICATION: + kwargs = { + "cluster": "wm", + "table_path": "/clickhouse/tables/wm/wiremind/alembic_version", + "replica_name": "{replica}", + } + else: + kwargs = {} + + with context.begin_transaction(): + patch_alembic_version(context, **kwargs) + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, include_object=include_object) + + if REPLICATION: + kwargs = { + "cluster": "wm", + "table_path": "/clickhouse/tables/wm/wiremind/alembic_version", + "replica_name": "{replica}", + } + else: + kwargs = {} + + with context.begin_transaction(): + patch_alembic_version(context, **kwargs) + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/example/alembic/clickhouse/script.py.mako b/example/alembic/clickhouse/script.py.mako new file mode 100644 index 0000000..9f9e8c2 --- /dev/null +++ b/example/alembic/clickhouse/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +Have a look to examples https://github.com/xzkostyan/clickhouse-sqlalchemy-alembic-example + +""" +from alembic import op +import clickhouse_sqlalchemy +from clickhouse_sqlalchemy import engines +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/example/alembic/clickhouse/versions/20250606-inital.py b/example/alembic/clickhouse/versions/20250606-inital.py new file mode 100644 index 0000000..86a1791 --- /dev/null +++ b/example/alembic/clickhouse/versions/20250606-inital.py @@ -0,0 +1,344 @@ +"""inital + +Revision ID: 55622187799e +Revises: +Create Date: 2025-06-06 18:28:21.202381 + +Have a look to examples https://github.com/xzkostyan/clickhouse-sqlalchemy-alembic-example + +""" + +import sqlalchemy as sa +from clickhouse_sqlalchemy import engines +from clickhouse_sqlalchemy.types import ( + Array, + Boolean, + Date, + DateTime64, + Decimal, + Int16, + Int32, + Int64, + LowCardinality, + Nullable, + String, +) + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "55622187799e" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "fact_daily_leg_bucket", + sa.Column("time", DateTime64(3, timezone="UTC")), + sa.Column("day_x", Int16), + sa.Column("event_datetime", DateTime64(3, timezone="UTC")), + sa.Column("transporter_type", LowCardinality(String)), + sa.Column("entity_name", LowCardinality(String)), + sa.Column("route_name", LowCardinality(String)), + sa.Column("service_departure_date", Date), + sa.Column("service_number", String), + sa.Column("service_labels", Array(String)), + sa.Column("service_status", LowCardinality(String)), + sa.Column("service_origin_station_name", String), + sa.Column("service_destination_station_name", String), + sa.Column("service_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("service_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("service_budget_objective", Nullable(Decimal(10, 2))), + sa.Column("service_yield_objective", Nullable(Decimal(10, 2))), + sa.Column("is_service_peak_leg", Boolean), + sa.Column("is_service_max_leg", Boolean), + sa.Column("leg_id", Int64), + sa.Column("leg_order", Int32), + sa.Column("leg_origin_station_name", String), + sa.Column("leg_destination_station_name", String), + sa.Column("leg_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("leg_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("leg_list_alerts", Array(String)), + sa.Column("reference_service_number", Nullable(String)), + sa.Column("reference_service_departure_date", Nullable(Date)), + sa.Column("cabin_name", LowCardinality(String)), + sa.Column("family_name", LowCardinality(String)), + sa.Column("bucket_name", LowCardinality(String)), + sa.Column("cumulative_sum_net_revenue_vat_inc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_revenue_vat_exc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_ancillary_revenue_vat_inc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_ancillary_revenue_vat_exc", Decimal(10, 2)), + sa.Column("sum_net_revenue_vat_inc", Decimal(10, 2)), + sa.Column("sum_net_revenue_vat_exc", Decimal(10, 2)), + sa.Column("sum_net_ancillary_revenue_vat_inc", Decimal(10, 2)), + sa.Column("sum_net_ancillary_revenue_vat_exc", Decimal(10, 2)), + sa.Column("availability_end_day", Nullable(Int32)), + sa.Column("cumul_availability_end_day", Int32), + sa.Column("cumulative_sum_net_bookings", Nullable(Int32)), + sa.Column("sum_net_bookings", Nullable(Int32)), + sa.Column("cabin_capacity", Nullable(Int32)), + sa.Column("cabin_lid", Nullable(Int32)), + sa.Column("bucket_authorization_end_day", Nullable(Int32)), + sa.Column("is_last", Boolean), + engines.ReplicatedMergeTree( + table_path="/clickhouse/tables/{shard}/fact_daily_leg_bucket/", + replica_name="{replica}", + primary_key=("time", "service_departure_date"), + order_by=( + "time", + "service_departure_date", + "day_x", + "service_number", + "leg_id", + "bucket_name", + ), + ), + sa.text("INDEX is_last_index is_last TYPE set GRANULARITY 1"), + clickhouse_cluster="wm", + ) + + op.create_table( + "fact_daily_leg_physical_inventory", + sa.Column("time", DateTime64(3, timezone="UTC")), + sa.Column("day_x", Int16), + sa.Column("event_datetime", DateTime64(3, timezone="UTC")), + sa.Column("transporter_type", LowCardinality(String)), + sa.Column("entity_name", LowCardinality(String)), + sa.Column("route_name", LowCardinality(String)), + sa.Column("service_number", String), + sa.Column("service_labels", Array(String)), + sa.Column("service_status", LowCardinality(String)), + sa.Column("service_departure_date", Date), + sa.Column("service_origin_station_name", String), + sa.Column("service_destination_station_name", String), + sa.Column("service_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("service_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("is_service_peak_leg", Boolean), + sa.Column("is_service_max_leg", Boolean), + sa.Column("service_max_leg_origin_station_name", String), + sa.Column("service_max_leg_destination_station_name", String), + sa.Column("leg_id", Int64), + sa.Column("leg_status", String), + sa.Column("leg_origin_station_name", String), + sa.Column("leg_destination_station_name", String), + sa.Column("leg_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("leg_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("leg_order", Int32), + sa.Column("leg_list_alerts", Array(String)), + sa.Column("cabin_name", LowCardinality(String)), + sa.Column("physical_inventory_name", LowCardinality(String)), + sa.Column("physical_inventory_capacity", Nullable(Int32)), + sa.Column("physical_inventory_lid", Nullable(Int32)), + sa.Column("physical_availability_start_day", Nullable(Int32)), + sa.Column("physical_availability_end_day", Nullable(Int32)), + sa.Column("cumulative_sum_net_revenue_vat_inc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_revenue_vat_exc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_ancillary_revenue_vat_inc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_ancillary_revenue_vat_exc", Decimal(10, 2)), + sa.Column("sum_net_revenue_vat_inc", Decimal(10, 2)), + sa.Column("sum_net_revenue_vat_exc", Decimal(10, 2)), + sa.Column("sum_net_ancillary_revenue_vat_inc", Decimal(10, 2)), + sa.Column("sum_net_ancillary_revenue_vat_exc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_bookings", Int32), + sa.Column("sum_net_bookings", Int32), + sa.Column("forecast_full_day_x", Nullable(Int32)), + sa.Column("optimization_full_day_x", Nullable(Int32)), + sa.Column("unconstrained_demand_bookings", Nullable(Decimal(10, 2))), + sa.Column("unconstrained_forecast_bookings", Nullable(Decimal(10, 2))), + sa.Column("is_last", Boolean), + engines.ReplicatedMergeTree( + table_path="/clickhouse/tables/{shard}/fact_daily_leg_physical_inventory/", + replica_name="{replica}", + primary_key=("time", "service_departure_date"), + order_by=( + "time", + "service_departure_date", + "day_x", + "service_number", + "leg_id", + "physical_inventory_name", + ), + ), + sa.text("INDEX is_last_index is_last TYPE set GRANULARITY 1"), + clickhouse_cluster="wm", + ) + + op.create_table( + "fact_daily_od_bucket", + sa.Column("time", DateTime64(3, timezone="UTC")), + sa.Column("day_x", Int32), + sa.Column("event_datetime", DateTime64(3, timezone="UTC")), + sa.Column("transporter_type", LowCardinality(String)), + sa.Column("entity_name", LowCardinality(String)), + sa.Column("route_name", LowCardinality(String)), + sa.Column("service_id", Int64), + sa.Column("service_departure_date", Date), + sa.Column("service_number", String), + sa.Column("service_labels", Array(String)), + sa.Column("service_status", String), + sa.Column("service_origin_station_name", String), + sa.Column("service_destination_station_name", String), + sa.Column("service_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("service_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("service_timezone", String), + sa.Column("service_budget_objective", Nullable(Decimal(10, 2))), + sa.Column("service_yield_objective", Nullable(Decimal(10, 2))), + sa.Column("service_capacity", Nullable(Int32)), + sa.Column("service_lid", Nullable(Int32)), + sa.Column("service_max_leg_load_factor", Nullable(Decimal(10, 2))), + sa.Column("service_max_leg_cumulative_sum_net_bookings", Nullable(Int32)), + sa.Column("service_optimization_status", LowCardinality(String)), + sa.Column("service_pointer", Boolean), + sa.Column("market_name", LowCardinality(String)), + sa.Column("market_rank", Int32), + sa.Column("market_list_alerts", Array(String)), + sa.Column("market_cabin_max_leg_load_factor", Nullable(Decimal(10, 2))), + sa.Column("market_cabin_max_leg_cumulative_sum_net_bookings", Nullable(Int32)), + sa.Column("od_id", Int64), + sa.Column("od_status", String), + sa.Column("od_origin_station_name", String), + sa.Column("od_destination_station_name", String), + sa.Column("od_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("od_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("od_rank", Int32), + sa.Column("reference_service_number", Nullable(String)), + sa.Column("reference_service_departure_date", Nullable(Date)), + sa.Column("cabin_name", String), + sa.Column("od_cabin_forecasted_traffic", Nullable(Decimal(10, 2))), + sa.Column("od_cabin_forecasted_revenue_vat_inc", Nullable(Decimal(10, 2))), + sa.Column("od_cabin_forecasted_revenue_vat_exc", Nullable(Decimal(10, 2))), + sa.Column("od_cabin_optimized_traffic", Decimal(10, 2)), + sa.Column("od_cabin_optimized_revenue_vat_inc", Nullable(Decimal(10, 2))), + sa.Column("od_cabin_optimized_revenue_vat_exc", Nullable(Decimal(10, 2))), + sa.Column("od_cabin_last_predicted", Nullable(Decimal(10, 2))), + sa.Column("od_cabin_last_observed", Nullable(Decimal(10, 2))), + sa.Column("od_cabin_pointer", Boolean), + sa.Column("family_name", LowCardinality(String)), + sa.Column("bucket_name", LowCardinality(String)), + sa.Column("bucket_order", Int32), + sa.Column("has_pricing_changed_day_x", Boolean), + sa.Column("has_pricing_changed_bucket", Boolean), + sa.Column("cumulative_sum_net_revenue_vat_inc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_revenue_vat_exc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_ancillary_revenue_vat_inc", Decimal(10, 2)), + sa.Column("cumulative_sum_net_ancillary_revenue_vat_exc", Decimal(10, 2)), + sa.Column("sum_net_revenue_vat_inc", Decimal(10, 2)), + sa.Column("sum_net_revenue_vat_exc", Decimal(10, 2)), + sa.Column("sum_net_ancillary_revenue_vat_inc", Decimal(10, 2)), + sa.Column("sum_net_ancillary_revenue_vat_exc", Decimal(10, 2)), + sa.Column("availability_start_day", Nullable(Int32)), + sa.Column("availability_end_day", Nullable(Int32)), + sa.Column("cumul_availability_start_day", Int32), + sa.Column("cumul_availability_end_day", Int32), + sa.Column("cumulative_sum_net_bookings", Nullable(Int32)), + sa.Column("sum_confirmed_bookings", Nullable(Int32)), + sa.Column("sum_net_bookings", Nullable(Int32)), + sa.Column("is_first_available_start_day", Boolean), + sa.Column("is_first_available_end_day", Boolean), + sa.Column("price_vat_inc", Nullable(Decimal(10, 2))), + sa.Column("cabin_capacity", Nullable(Int32)), + sa.Column("cabin_lid", Nullable(Int32)), + sa.Column("bucket_authorization_start_day", Nullable(Int32)), + sa.Column("bucket_authorization_end_day", Nullable(Int32)), + sa.Column("forecast_full_day_x", Nullable(Int32)), + sa.Column("optimization_full_day_x", Nullable(Int32)), + sa.Column("unconstrained_demand_bookings", Nullable(Decimal(10, 2))), + sa.Column("unconstrained_demand_revenue", Nullable(Decimal(10, 2))), + sa.Column("unconstrained_forecast_bookings", Nullable(Decimal(10, 2))), + sa.Column("unconstrained_forecast_revenue", Nullable(Decimal(10, 2))), + sa.Column("is_last", Boolean), + engines.ReplicatedMergeTree( + table_path="/clickhouse/tables/{shard}/fact_daily_od_bucket/", + replica_name="{replica}", + primary_key=("time", "service_departure_date"), + order_by=( + "time", + "service_departure_date", + "service_number", + "day_x", + "service_id", + "od_id", + "bucket_name", + ), + ), + sa.text("INDEX is_last_index is_last TYPE set GRANULARITY 1"), + clickhouse_cluster="wm", + ) + + op.create_table( + "fact_passenger_event", + sa.Column("time", DateTime64(3, timezone="UTC")), + sa.Column("day_x", Int32), + sa.Column("event_datetime", DateTime64(3, timezone="UTC")), + sa.Column("transporter_type", LowCardinality(String)), + sa.Column("entity_name", LowCardinality(String)), + sa.Column("route_name", LowCardinality(String)), + sa.Column("service_id", Int64), + sa.Column("service_departure_date", Date), + sa.Column("service_number", String), + sa.Column("service_labels", Array(String)), + sa.Column("service_status", LowCardinality(String)), + sa.Column("service_origin_station_name", String), + sa.Column("service_destination_station_name", String), + sa.Column("service_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("service_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("service_timezone", LowCardinality(String)), + sa.Column("market_id", Int64), + sa.Column("market_name", LowCardinality(String)), + sa.Column("market_rank", Int32), + sa.Column("od_id", Int64), + sa.Column("od_status", String), + sa.Column("od_origin_station_name", String), + sa.Column("od_destination_station_name", String), + sa.Column("od_destination_station_id", Int64), + sa.Column("od_departure_datetime", DateTime64(3, timezone="UTC")), + sa.Column("od_arrival_datetime", DateTime64(3, timezone="UTC")), + sa.Column("od_rank", Int32), + sa.Column("cabin_name", LowCardinality(String)), + sa.Column("family_name", LowCardinality(String)), + sa.Column("bucket_name", LowCardinality(String)), + sa.Column("bucket_order", Int32), + sa.Column("cabin_capacity", Nullable(Int32)), + sa.Column("cabin_lid", Nullable(Int32)), + sa.Column("physical_inventory_name", String), + sa.Column("price_vat_inc", Decimal(10, 2)), + sa.Column("price_vat_exc", Decimal(10, 2)), + sa.Column("base_price_vat_inc", Decimal(10, 2)), + sa.Column("bucket_price_vat_inc", Nullable(Decimal(10, 2))), + sa.Column("ancillary_revenue_vat_inc", Decimal(10, 2)), + sa.Column("ancillary_revenue_vat_exc", Decimal(10, 2)), + sa.Column("event_type", LowCardinality(String)), + sa.Column("booking_key", String), + sa.Column("customer_key", Nullable(String)), + sa.Column("ticket_category", Nullable(String)), + sa.Column("ticket_key", String), + sa.Column("fare_code", Nullable(String)), + sa.Column("sale_channel_name", Nullable(String)), + sa.Column("sale_channel_type", Nullable(String)), + sa.Column("is_ticket_exchanged", Boolean), + sa.Column("is_confirmed", Boolean), + sa.Column("no_show", Boolean), + engines.ReplicatedMergeTree( + table_path="/clickhouse/tables/{shard}/fact_passenger_event/", + replica_name="{replica}", + primary_key=("time", "service_departure_date"), + order_by=( + "time", + "service_departure_date", + "service_number", + "day_x", + "service_id", + "od_id", + ), + ), + clickhouse_cluster="wm", + ) + + +def downgrade() -> None: + op.drop_table("fact_daily_leg_bucket") + op.drop_table("fact_daily_leg_physical_inventory") + op.drop_table("fact_daily_od_bucket") + op.drop_table("fact_passenger_event") diff --git a/example/alembic/postgresl/README b/example/alembic/postgresl/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/example/alembic/postgresl/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/example/alembic/env.py b/example/alembic/postgresl/env.py similarity index 100% rename from example/alembic/env.py rename to example/alembic/postgresl/env.py diff --git a/example/alembic/script.py.mako b/example/alembic/postgresl/script.py.mako similarity index 100% rename from example/alembic/script.py.mako rename to example/alembic/postgresl/script.py.mako diff --git a/example/alembic/versions/init.py b/example/alembic/postgresl/versions/init.py similarity index 100% rename from example/alembic/versions/init.py rename to example/alembic/postgresl/versions/init.py diff --git a/pyproject.toml b/pyproject.toml index 4fcc0d2..26d9527 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "alembic", "psycopg2", "wiremind-kubernetes~=7.0", + "numpy >1.23.6", ] [build-system] diff --git a/src/chartreuse/chartreuse.py b/src/chartreuse/chartreuse.py index e2f474b..fa20ce4 100644 --- a/src/chartreuse/chartreuse.py +++ b/src/chartreuse/chartreuse.py @@ -72,12 +72,21 @@ def __init__( db_name = db_config["name"] logger.info(f"Initializing migration helper for database: {db_name}") + # Build additional parameters with section name + additional_params = db_config.get("additional_parameters", "") + + # Use database name as section name for alembic -n parameter + section_param = f"-n {db_name}" + + if section_param: + additional_params = f"{additional_params} {section_param}".strip() + helper = AlembicMigrationHelper( alembic_directory_path=db_config["alembic_directory_path"], alembic_config_file_path=db_config["alembic_config_file_path"], database_url=db_config["url"], allow_migration_for_empty_database=db_config.get("allow_migration_for_empty_database", False), - additional_parameters=db_config.get("additional_parameters", ""), + additional_parameters=additional_params, ) self.migration_helpers[db_name] = helper From 747710ddf1032ae1cec155856470b8e6069b9cd5 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:47:51 +0200 Subject: [PATCH 12/13] fix(migration): multi-db support to AlembicMigrationHelper on the alembic.ini file substituion --- example/Dockerfile-dev-auth | 2 +- example/alembic/clickhouse/env.py | 3 +- .../clickhouse/versions/20250606-inital.py | 3 +- src/chartreuse/chartreuse.py | 1 + .../tests/unit_tests/test_alembic.py | 208 ++++++++++++++++++ .../tests/unit_tests/test_chartreuse.py | 3 +- .../utils/alembic_migration_helper.py | 35 ++- 7 files changed, 246 insertions(+), 9 deletions(-) diff --git a/example/Dockerfile-dev-auth b/example/Dockerfile-dev-auth index 104de2f..8c7cc61 100644 --- a/example/Dockerfile-dev-auth +++ b/example/Dockerfile-dev-auth @@ -14,7 +14,7 @@ COPY VERSION . COPY README.md . COPY requirements.txt* ./ COPY src/ src/ -COPY alembic/ alembic/ +COPY example/alembic/ alembic/ RUN if [ -f requirements.txt ]; then uv pip install --system --no-cache -r requirements.txt; fi diff --git a/example/alembic/clickhouse/env.py b/example/alembic/clickhouse/env.py index ce4fbb7..36d308a 100644 --- a/example/alembic/clickhouse/env.py +++ b/example/alembic/clickhouse/env.py @@ -1,8 +1,7 @@ import logging -from sqlalchemy import engine_from_config, pool - from alembic import context +from sqlalchemy import engine_from_config, pool # inspired by https://github.com/xzkostyan/clickhouse-sqlalchemy-alembic-example/blob/main/simple/migrations/env.py diff --git a/example/alembic/clickhouse/versions/20250606-inital.py b/example/alembic/clickhouse/versions/20250606-inital.py index 86a1791..9173c8e 100644 --- a/example/alembic/clickhouse/versions/20250606-inital.py +++ b/example/alembic/clickhouse/versions/20250606-inital.py @@ -9,6 +9,7 @@ """ import sqlalchemy as sa +from alembic import op from clickhouse_sqlalchemy import engines from clickhouse_sqlalchemy.types import ( Array, @@ -24,8 +25,6 @@ String, ) -from alembic import op - # revision identifiers, used by Alembic. revision = "55622187799e" down_revision = None diff --git a/src/chartreuse/chartreuse.py b/src/chartreuse/chartreuse.py index fa20ce4..b5f6c36 100644 --- a/src/chartreuse/chartreuse.py +++ b/src/chartreuse/chartreuse.py @@ -87,6 +87,7 @@ def __init__( database_url=db_config["url"], allow_migration_for_empty_database=db_config.get("allow_migration_for_empty_database", False), additional_parameters=additional_params, + alembic_section_name=db_name, ) self.migration_helpers[db_name] = helper diff --git a/src/chartreuse/tests/unit_tests/test_alembic.py b/src/chartreuse/tests/unit_tests/test_alembic.py index 344f416..21dcbe5 100644 --- a/src/chartreuse/tests/unit_tests/test_alembic.py +++ b/src/chartreuse/tests/unit_tests/test_alembic.py @@ -1,3 +1,6 @@ +import os +import tempfile + from pytest_mock.plugin import MockerFixture import chartreuse.utils @@ -179,3 +182,208 @@ def test_additional_parameters_current(mocker: MockerFixture) -> None: mocked_run_command.assert_called_with( "alembic -c alembic.ini foo bar current", cwd="/app/alembic", return_result=True ) + + +def test_multi_database_configuration_postgresql_section(mocker: MockerFixture) -> None: + """ + Test that multi-database configuration correctly updates PostgreSQL section in alembic.ini + """ + # Sample alembic.ini content with multiple sections + sample_alembic_ini = """[postgresql] +script_location = postgresl +sqlalchemy.url = postgresql://wiremind_owner@localhost:5432/wiremind +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s + +[clickhouse] +script_location = clickhouse +sqlalchemy.url = clickhouse://default@localhost:8123/wiremind +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s + +[loggers] +keys = root,sqlalchemy,alembic +""" + + with tempfile.TemporaryDirectory() as temp_dir: + # Create a temporary alembic.ini file + alembic_ini_path = os.path.join(temp_dir, "alembic.ini") + with open(alembic_ini_path, "w") as f: + f.write(sample_alembic_ini) + + # Test configuring PostgreSQL section + chartreuse.utils.AlembicMigrationHelper( + alembic_directory_path=temp_dir, + alembic_config_file_path="alembic.ini", + database_url="postgresql://new_user:new_pass@new_host:5432/new_db", + alembic_section_name="postgresql", + configure=True, + skip_db_checks=True, + ) + + # Read the file and verify PostgreSQL section was updated + with open(alembic_ini_path) as f: + content = f.read() + + # Verify PostgreSQL URL was updated + assert "postgresql://new_user:new_pass@new_host:5432/new_db" in content + # Verify ClickHouse URL was NOT changed + assert "clickhouse://default@localhost:8123/wiremind" in content + # Verify sections are still intact + assert "[postgresql]" in content + assert "[clickhouse]" in content + assert "[loggers]" in content + + +def test_multi_database_configuration_clickhouse_section(mocker: MockerFixture) -> None: + """ + Test that multi-database configuration correctly updates ClickHouse section in alembic.ini + """ + # Sample alembic.ini content with multiple sections + sample_alembic_ini = """[postgresql] +script_location = postgresl +sqlalchemy.url = postgresql://wiremind_owner@localhost:5432/wiremind +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s + +[clickhouse] +script_location = clickhouse +sqlalchemy.url = clickhouse://default@localhost:8123/wiremind +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s + +[loggers] +keys = root,sqlalchemy,alembic +""" + + with tempfile.TemporaryDirectory() as temp_dir: + # Create a temporary alembic.ini file + alembic_ini_path = os.path.join(temp_dir, "alembic.ini") + with open(alembic_ini_path, "w") as f: + f.write(sample_alembic_ini) + + # Test configuring ClickHouse section + chartreuse.utils.AlembicMigrationHelper( + alembic_directory_path=temp_dir, + alembic_config_file_path="alembic.ini", + database_url="clickhouse://new_user:new_pass@new_host:8123/new_db", + alembic_section_name="clickhouse", + configure=True, + skip_db_checks=True, + ) + + # Read the file and verify ClickHouse section was updated + with open(alembic_ini_path) as f: + content = f.read() + + # Verify ClickHouse URL was updated + assert "clickhouse://new_user:new_pass@new_host:8123/new_db" in content + # Verify PostgreSQL URL was NOT changed + assert "postgresql://wiremind_owner@localhost:5432/wiremind" in content + # Verify sections are still intact + assert "[postgresql]" in content + assert "[clickhouse]" in content + assert "[loggers]" in content + + +def test_multi_database_configuration_both_sections(mocker: MockerFixture) -> None: + """ + Test that multi-database configuration correctly updates both sections independently + """ + # Sample alembic.ini content with multiple sections + sample_alembic_ini = """[postgresql] +script_location = postgresl +sqlalchemy.url = postgresql://wiremind_owner@localhost:5432/wiremind +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s + +[clickhouse] +script_location = clickhouse +sqlalchemy.url = clickhouse://default@localhost:8123/wiremind +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s + +[loggers] +keys = root,sqlalchemy,alembic +""" + + with tempfile.TemporaryDirectory() as temp_dir: + # Create a temporary alembic.ini file + alembic_ini_path = os.path.join(temp_dir, "alembic.ini") + with open(alembic_ini_path, "w") as f: + f.write(sample_alembic_ini) + + # Configure PostgreSQL section first + chartreuse.utils.AlembicMigrationHelper( + alembic_directory_path=temp_dir, + alembic_config_file_path="alembic.ini", + database_url="postgresql://pg_user:pg_pass@pg_host:5432/pg_db", + alembic_section_name="postgresql", + configure=True, + skip_db_checks=True, + ) + + # Configure ClickHouse section second + chartreuse.utils.AlembicMigrationHelper( + alembic_directory_path=temp_dir, + alembic_config_file_path="alembic.ini", + database_url="clickhouse://ch_user:ch_pass@ch_host:8123/ch_db", + alembic_section_name="clickhouse", + configure=True, + skip_db_checks=True, + ) + + # Read the file and verify both sections were updated correctly + with open(alembic_ini_path) as f: + final_content = f.read() + + # Verify both URLs are now updated correctly + assert "postgresql://pg_user:pg_pass@pg_host:5432/pg_db" in final_content + assert "clickhouse://ch_user:ch_pass@ch_host:8123/ch_db" in final_content + # Verify original URLs are gone + assert "postgresql://wiremind_owner@localhost:5432/wiremind" not in final_content + assert "clickhouse://default@localhost:8123/wiremind" not in final_content + # Verify sections are still intact + assert "[postgresql]" in final_content + assert "[clickhouse]" in final_content + assert "[loggers]" in final_content + + +def test_single_database_configuration_still_works(mocker: MockerFixture) -> None: + """ + Test that single-database configuration (legacy behavior) still works when alembic_section_name is None + """ + # Sample simple alembic.ini content (single database) + sample_alembic_ini = """[alembic] +script_location = alembic +sqlalchemy.url = postgresql://old@localhost:5432/old +prepend_sys_path = .. +file_template = %%(year)d%%(month).2d%%(day).2d-%%(slug)s +""" + + with tempfile.TemporaryDirectory() as temp_dir: + # Create a temporary alembic.ini file + alembic_ini_path = os.path.join(temp_dir, "alembic.ini") + with open(alembic_ini_path, "w") as f: + f.write(sample_alembic_ini) + + # Test single database configuration (alembic_section_name=None) + chartreuse.utils.AlembicMigrationHelper( + alembic_directory_path=temp_dir, + alembic_config_file_path="alembic.ini", + database_url="postgresql://new_user:new_pass@new_host:5432/new_db", + alembic_section_name=None, # Single database mode + configure=True, + skip_db_checks=True, + ) + + # Read the file and verify URL was updated + with open(alembic_ini_path) as f: + content = f.read() + + # Verify URL was updated + assert "postgresql://new_user:new_pass@new_host:5432/new_db" in content + # Verify original URL is gone + assert "postgresql://old@localhost:5432/old" not in content + # Verify section is still intact + assert "[alembic]" in content diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse.py b/src/chartreuse/tests/unit_tests/test_chartreuse.py index 0053f22..b50a6a6 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse.py @@ -412,5 +412,6 @@ def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: alembic_config_file_path="alembic.ini", database_url="postgresql://user:pass@localhost:5432/db", allow_migration_for_empty_database=False, # Default value - additional_parameters="", # Default value + additional_parameters="-n test", # Section name parameter added + alembic_section_name="test", # New parameter for multi-database support ) diff --git a/src/chartreuse/utils/alembic_migration_helper.py b/src/chartreuse/utils/alembic_migration_helper.py index 0759747..542f4f4 100755 --- a/src/chartreuse/utils/alembic_migration_helper.py +++ b/src/chartreuse/utils/alembic_migration_helper.py @@ -22,6 +22,8 @@ def __init__( additional_parameters: str = "", allow_migration_for_empty_database: bool = False, configure: bool = True, + alembic_section_name: str | None = None, + skip_db_checks: bool = False, ): if not database_url: raise ValueError("database_url not set, not upgrading database.") @@ -31,31 +33,58 @@ def __init__( self.additional_parameters = additional_parameters self.alembic_directory_path = alembic_directory_path self.alembic_config_file_path = alembic_config_file_path + self.alembic_section_name = alembic_section_name + self.skip_db_checks = skip_db_checks # Chartreuse will upgrade a PG managed/configured by postgres-operator self.is_patroni_postgresql: bool = "CHARTREUSE_PATRONI_POSTGRESQL" in os.environ - if self.is_patroni_postgresql: + if self.is_patroni_postgresql and not skip_db_checks: self.additional_parameters += " -x patroni_postgresql=yes" self._wait_postgres_is_configured() if configure: self._configure() - self.is_migration_needed = self._check_migration_needed() + if not skip_db_checks: + self.is_migration_needed = self._check_migration_needed() + else: + self.is_migration_needed = False def _configure(self) -> None: with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}") as f: content = f.read() + + if self.alembic_section_name: + # Multi-database configuration: update specific section + # Find the section and update sqlalchemy.url within that section only + section_start = rf"\[{re.escape(self.alembic_section_name)}\]" + section_pattern = ( + rf"({section_start}.*?)" + rf"(sqlalchemy\.url\s*=\s*[^\n]*)" + rf"(.*?)(?=\n\[|\Z)" + ) + content_new = re.sub( + section_pattern, + rf"\1sqlalchemy.url = {self.database_url}\3", + content, + flags=re.DOTALL | re.M, + ) + else: + # Single database configuration: replace any sqlalchemy.url content_new = re.sub( "(sqlalchemy.url.*=.*){1}", rf"sqlalchemy.url={self.database_url}", content, flags=re.M, ) + if content != content_new: with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}", "w") as f: f.write(content_new) - logger.info("alembic.ini was configured.") + config_type = ( + f"section {self.alembic_section_name}" if self.alembic_section_name else "default configuration" + ) + logger.info(f"alembic.ini was configured for {config_type}.") else: raise SubprocessError("configuration of alembic.ini has failed (alembic.ini is unchanged)") From d44c422fe844c651671a1fca735cd0dddb6bebe8 Mon Sep 17 00:00:00 2001 From: nahuel11500 <134035119+nahuel11500@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:23:45 +0200 Subject: [PATCH 13/13] fix(review): fix based on review --- .gitignore | 4 + .../helm-chart/my-example-chart/Chart.lock | 9 - .../charts/chartreuse-5.0.3.tgz | Bin 5432 -> 0 bytes .../charts/postgresql-8.6.3.tgz | Bin 32017 -> 0 bytes pyproject.toml | 3 +- requirements.txt | 67 +- src/chartreuse/chartreuse.py | 75 +-- src/chartreuse/chartreuse_upgrade.py | 153 ++--- src/chartreuse/config_loader.py | 145 +++-- src/chartreuse/tests/conftest.py | 46 ++ src/chartreuse/tests/e2e_tests/conftest.py | 9 +- .../tests/e2e_tests/test_blackbox.py | 31 - .../tests/e2e_tests/test_integration.py | 32 - .../tests/unit_tests/test_alembic.py | 40 +- .../tests/unit_tests/test_chartreuse.py | 330 ++++++---- .../test_chartreuse_upgrade_extended.py | 172 +++-- .../tests/unit_tests/test_config_loader.py | 586 +++++++++++++----- .../utils/alembic_migration_helper.py | 64 +- 18 files changed, 1087 insertions(+), 679 deletions(-) delete mode 100644 example/helm-chart/my-example-chart/Chart.lock delete mode 100644 example/helm-chart/my-example-chart/charts/chartreuse-5.0.3.tgz delete mode 100644 example/helm-chart/my-example-chart/charts/postgresql-8.6.3.tgz delete mode 100644 src/chartreuse/tests/e2e_tests/test_blackbox.py delete mode 100644 src/chartreuse/tests/e2e_tests/test_integration.py diff --git a/.gitignore b/.gitignore index c250529..b40fe5f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,7 @@ venv-python3.7 venv-python3.8 venv-python3.10 .python-version + +# Helm Chart +*.lock +example/helm-chart/my-example-chart/charts/ \ No newline at end of file diff --git a/example/helm-chart/my-example-chart/Chart.lock b/example/helm-chart/my-example-chart/Chart.lock deleted file mode 100644 index e7fe59f..0000000 --- a/example/helm-chart/my-example-chart/Chart.lock +++ /dev/null @@ -1,9 +0,0 @@ -dependencies: -- name: postgresql - repository: https://charts.helm.sh/stable - version: 8.6.3 -- name: chartreuse - repository: https://wiremind.github.io/wiremind-helm-charts - version: 5.0.3 -digest: sha256:67839e48e486a2d776898435e506b21f8c5d16820898f2601612c993d017fa12 -generated: "2025-10-07T12:15:59.35123506+02:00" diff --git a/example/helm-chart/my-example-chart/charts/chartreuse-5.0.3.tgz b/example/helm-chart/my-example-chart/charts/chartreuse-5.0.3.tgz deleted file mode 100644 index 276be546461b7302861c484e3cb6100e792d1b43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5432 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 diff --git a/example/helm-chart/my-example-chart/charts/postgresql-8.6.3.tgz b/example/helm-chart/my-example-chart/charts/postgresql-8.6.3.tgz deleted file mode 100644 index 7dee893eb8c1fb58ba9ebca63b5fa9c73c12bda6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32017 zcmV*8KykkxiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PMYcciXnID1QFdrjz!`z7!2lx!62brj2PyBgx&oyVxlua zQTQLLeYUo?wqCt_ss7#C+A9CMy|uk#|JLufpYOcf`43nz7}Zb81!DiPwRPKe%7gn# z9+Z$@Fyn;AJ-FI#p(M$EZf|#9b+%doP8jx)z(EfLOK~f}+-D>as?fpV>Dl4_-o@bo zDEfgRWpJvA>imD-ZUTyd4*rdO;V~Q{SqX&@Ab|#mV~9|UMmWN;XaVpE7dV3TcV`E$ zkIy&YnuxKikE3LaIN?BJz@G$;IT?_UhzX!LkT1DFae!FBJ)cHNNKowKP7B8)65}3> zs7S)rm`1nvpfNuRA&EcPJZmTf~z4d8(`&B22N3D-|a!pyldo2(0g3gbYmm#C&f&RvD5)xnGs^x(nrd(jw zijf?E(+H<2^Ra5$0zD#PoDLMs27ddm7f}BrW}Z|3%1-q6o!!p!)i`f*6Xhz>K%n*5EY6 zh~rI&slXt{NPrj<4%Z|MfnH(8NFd+ismhx`Vn0j+5|1Fj3629C`vmh%@M%0Gqmho^OG4n<#32;|+z`JV+!(NhxA&Ezxey3WJ6p(@11Gg*P)4N2gEa3#W9bycKF*E@> zoT7w|!G`_?QOX4jl$w*OHhhMqCIuu`^e$yOPmt7BisRzd0~3gUbfK=}03Qjs%F5#T;Jmo&T&B=E>gi5d?UUjz*F{kQgEolK>AKT+GsJ zOlePb%)4u{d5^~40P(Sxwc7&SM-PT5f%%inWY&B~e0D%p)$f<>02;j=w0 z!ETmkICsx-vJp?Qoq3E$^Na$8%R#10ntl5;7Wk9l&?i*jo*JA#rq{8hX=zo#kcNR& z6F3AkHk?YgVVdYLOWnFL$(Ld(R0{(f((CDE)S1)7uVWlT_D=R~XiH6~)o!4TQbF5N zK`M+<@fWGO2nzoAnyP8j4Z9-}6lDECF87GUIFKq7`>&B)pPP_!$!vTYMJNt<5Bekk zFYWi#zi;3M{8WG!w4n{&aHp@E*8oX)v4J$4$X17GsEqn|369UlWGLWzO#Cs75eM%3 z+DZ>5a^?C83KDQwmx$bwjY({xFr?Qw*f3J=Uo?dXP2d`74x_a{!WY5yj~3imj&nd_ zEpj9It>>-f9m|!} zYboeXX)wKhZoT|1X4EVENhkm+(3MFB3lB5UQTU*<$0y5Q`9Ab7wd<=WOFJ;p0 z0nXoZ-L!{v)7_SNVY;f6XOi1U0ZoT`rWr=T1n?)qgcp%_!1>aILUrpGa~ew3aO963VpW1 z=cZ=tht6)7rLiCp_VoOC*C_0aMHH^RdB5-dy7$(5d-%)YTje6mfZJEF^`K3M!?y13 zCq^SJ=F(`O)ti1{pnVANP_auipjUWPv*tntF{a!Yxr!H$(G@P>#P!Z;l6ZG<*pvFL zk7CK~1{nTP#BU2^2j_VsIpVi;8)tc=u& zl+xj}qSygMlS}8FS!$WVsaQ@%H?W>^9Kf{{Y5jcehk~1ZBQ;`v#R$(00Dm6n{*uK)bV$Xh&pNp$NeNVLhnwLP;nTWP8?{ks53e7_ULqBq-10Pm}@vgrX$GJw5w^p~Lt}{gC#9 zru?7Yz0)s1i`gEu2Z*&blJYv)U-02~1x+T+rAjm;qhpj>{F7~elO z*1a*+tP6yQU;v1X_#GkLo%kcw+a4S7A}k2}lnWXuajb-b6dO|3Im96tiaGMt=a7!> z4cuQ*C`qMEt5!SHlF1p2Q4fj*9EXQq;sNnRX8fQChx^KFEtPNC*ylDMBZh<5>2Qb{ zZ}*`6a%=1Ob^D7VOry7xD=f8|2~DLuha?s9Z{|8`BovUw$_^W2#aVb)t}*Nj$|h4- ztW$h^?cMHjA$7o#4y5#-GWPb}@lT;o?TrRFn8xECw+hc76(HrmDtM{9dSk2{S{1eC zpyb(bZLE2JqW1Ml(avlTxu@y z<;7%Src@xBa?qH z+F-D?b6tS3408$jTIp-lC#*}BCh0ZzuW{fw=0qag-Kf|o!6zr-nzE08LaH_<9Dsnb z8VJjv8wIr>luP0_Gb;ZI#g2qeD-A&@}0M zcq98e#f;G4TtnA`=h~b0_F(UHiea?EKvF`L>w|?oXt$NKj$L79ek7C$`zOOg^2ruT zhxrf5)DBMU4-yB3ADPpnry57hA73INSk|3-GbC~?+jssLORZG>5FvMshk|vYeLlre znzude7dWL{@L_27iYI77h87teQE#eG`k^q>JgrA!#t zl+_R4QWU&KA&Py>^hTX6=Yqf+ZdqnTVk&bl#zGPdiAVNbcTKkFNq8i{`Cg@kuxFq@ zW%Nq&3=~hK^*{`nE5D0EA2-hF2u&1Mz<;EK;lRLpF~(-Bgaej;sl5@UJlajSP2hA> z;lkJ*n+hqqn&b>Rjsa*4oinrbM;UZf^jiyDSX4wozJXdst&2^$4oR%UK?pRK3vh+l zwJj^%$r~5|ax{kySVyN8*%DaDq1Dl5`qO;RweMrjJ;%{h;lcJpng(8?=afm?7GI+< z?5;7)1tY%jOfy5u6zCRm>%6TV!MTF;48K~xiLmpM2nzSwm;L$7g9gpv@vo(V@j|TtH0F#H6V!bHHat~+Knaojp2x1 z8DG9~%jeVEvE)?Vt;i!F@;>@%zX)EvdIkA>NexvA-qn|eiAEQgh$GM;M{8?vG_(%!%|dvR_7wlO z`|2O2yxGibd+n+!?e?ab{cNMdZVjq+E=Ne>k!edRw%Q%PuC{FTLfVl0)N~W4VYmq~ zo$5;MrUK#G?ZdIy1b<8^*V`>wTY0aTbpX~!$&Q?*y6HK*QB;D=`2M^9L3Vvlo;CHbSWSsnod+_}mQlUdg z@=TKe34AUX5_mKzh1pAKCggx5pHe}Of@^`s-A~j0$Yxr-hZDpyQcGu+pa~vv?TW!mBtR9L3 z558lp`D8#pWv@*PP7gjj3+S^=c$U?C_PgO)d$}Dmy&isXBJ#3`RRyK~qPDy&W|}ot z)l9Jk;3U94V%)li6B=Y9ptL8W-q`ZyXTC2pp(!)rVR07K1v$F%@1;+tRS|NgfHV>F zuE6O|ekt|?Y7Wd?DoYZ!cb>mc{MNv1_JT_M=v*?-5<5~1^@$rjaihN^H=4me^kP^I zFLK*@Vnvm#2<+|#28OlYMR~F?7hFeJNt?B(%*~BV_Gy%)Dm>SCa#XBd_DdfO(S2wY zRXaI)u=8^Fn5Z{?lFFZ3QLAOVOa?D{kcY}F%0kzq_I=_O%Lcz1*iciM$dfFy8uGCE7H*H5Wz@Z8%NRn{U(NVa@h*VWJm0L~H57+w#Dq@&2 zX)EZ~%%-1O(N64_8LH9Yb2?;&P!r`zkk+1FhHGm;V*`b>5;xtls%_a^qIM=?>}&7R zkkLrFHZ_`bGwWqwA;8iEvw>brY-C7M`=y7Uw6o8822?9#2>4SH5}&B>Y83*`Ew7Wn zEAK-*a!l{fUku6&UQVgnJU{{v!&yAJJHgL*9$%hYQL0LWgg>_4FKP8Kg0uljV{DpL z*jA^&C=K*9Y%0@~VdQHR9K1FTmDj1zdr~CAd=sv*2_{x$%$AUzFd}JEP%{dHWDQ6L ztU9Nr-9f$z8MxLqu?lcMk_ z-qijD$)ap)zkez5Djofzyvz%UAk`js#`z>RFtrop_nBQNX3)17qJ zoXYCWM#~mHmA|X}Ia1;i6dvFZP27DcfTXxrwJqOC5~QifUcPJr3=s)ahA+kp^DzyB z9_%Vtfucy~?RKk`LA)3^FYgAL!h}XxFyeDbe3S{ylu54NRfk9C9NuU)&SqBCj#W`( zGGv%ArIg@QkpcG^lGWN08STmQbB36A=ql+ z^&rx%3&7TI?dIRrouWClPmrog{ zyqja%v+Q6^$CcTs)`7@s23zjT$Z;)>Xa~*hwmD}JW;&rcvUH&su9QOmPPwpS=ep=7 zBzmAIK#5|&kwmWws8CzpoxGuA^brG>q%H*njk#cm7%`$6v(nx~YB_N!mZUlk^F$$* zl=yQx!cIK*TQthbNKH3kJxE5EPPq1Eh(@1Hdq1{6f6k8^wkwVYwxI)GzIbLKw!c6Z zp6_mL!9P?GzwKuz`Sy0?ya`LP0hHhT3NvNiNTE!0+`j!>u{L$BLIMys>|9b}Gw{o* z6Vhiu2NPLeqTm}%#9zL&W$7_Z**f3o6oF5p2uTgqY0HIXv38d{t7u2)WlSiycl2G z#pGQP;Y;n$xJPhH#VsT4odLE^jaTcQDFp`=A~P%?73K^yU}U2E}WinJx(@nkMZhE zMY)>vb2`xM>DojyNUliY6ziChz($&I9YGP`*jJ)M?|}17;AtW?jZMJ9HwQ}bvnvb( zjAQtSaUxq#@>YA0CKxf^fuooU6oxi#mkX8}|Dd#Zt}vSbClLt|+tl`t)?*YbmuBRb zYK#hc9cV!W1Szw{h}*?_-=gck6~6w_caO=LY0tiGv|9ase}MS7^$o}wHa!J_otRA6 zXzX{~M0)0LTh+h;(ZsV^R7$Wof_hDp#q)AyPxPfh*l83&bg zQ775bDKgZe5*&xaEJlZ69WtYf^^2>g4{wP0IUP8|lr)-?{xzCxO3K!4jZu>5!+kNo z#thVsf}-96B+BDFvgf!MRjzgGgH`c_^H_?FI^5a%VfTlP%*Ijv5uZkpY&S%{4r>kw z_fzHV?6g|nerpn%Nq7?^lz01jOhW9Uh{FJzv}GZk-~hh;w$*YEv!FP4GUEWQ2-2GZ zbq28fXpCQ-)dPk!4C%G(v}V@v9~EWo5$YWN_QrE8MIukX$de*9)#s^7i{Rg>myvgTeI4|Uk7MKRB8WkXmVg@1DzCrFHc zY=4$^AaaJ8orud0X@6Kz&;J|MghmQ2&jX}o@vn#%|g#(Ld;BjNF*2% z%v_0?amdp0Vz#^NdenAkvRuQZVm917k44~lLQKFT@)wq>s`&BQ3BJPYSu+fI+A~wz zwfbt2l^ceac%F7rHgC`ki} zB6;DROo~pfbxx#iTinbi+*-X;JL`G}eVHk=8VRzSw)v`4nnB@+u9g;MZdJQb^~P^1 zxJ_1SS|}X0n_9)Zm&uvz5MkRMh-znew7UW7ERTf5VR$F+4h}DmPD^Y~1!^^1!$syr z1djNJt=~IbAd&J83IZl|5cSUd_|o2(0^#)C*+mJ6WxD4H_7&-={_F{@FYPQey5Y=k zSnYufVci&CMe;UGSr*>_`0~YJOzmpUWC+1s@!uHqgXbw!e3Rkg5v_7fW9oZ>(A@K4 zGl?zBK@_102=oyHf+4B(uv7i}c@GHODkUHjL zwATRX|8*|}vAy%Ev(?$^Y(Jt&ZmzHoy0;=Kv$GlmmkX^ zQLYsP38R7bCAtTmI<47+`&91#T#j*=V8%NlS;bWlv-f|V?`%KcE$#oj+}+)J+W)za=kw?8w{S(Go(lUN zk`PM)`(q^0;*W6;zU_Yb(vqcGho2I2U|jnu%{IC6mv&mFoM)og3S3(YQ!g)|j^^qx zG$odEd(=sEE_pCZV;^>(t3OF}uF`fu+shl19L9)%?zgQxHJ|B2XIuXxMIjjy8~{oZ zolCRzE7q;6@RVO+T?X<>r> zjoFyVRSi2JM;Mngzh`A?3pB!*6x8;|H)>eDADd5#17{{_bt?r$D~Gl4c3}V$Mq)99 z_J4BkKc&1cw5I!AgiPxH{u-%ApO38|qUERco9q?>r}p~%S=g9QwR5%;YGynKsI7T)!Kc}EhUX{Gaq2n(2OuWc?;qcQJZZ4av07F?DY%Upj0h=WQ;Y}Se{rJRkkF{=u; zYgvlBH1ywg95*VSR9cMf@7mD5Y)E=_bRww)v+3)7)H$(P129hdY>hJKLL#|N%eIBN z{m3bAt&O@GnKhEtktWDg!IEW^aZbIt-r3%^K@TRsd3MNECt208x6)3@vb<&qxs`>I z<%0n3(*$p}2B|PR_njXUE$no*w;-UH$IpcA42akSPT?A>2yc~3DM~_ht)SUa9(szd zLpmaUr?nSyY7hB56Y7``0%#D_ZcUg-AR$C=C|suVYi1{wcd#3y6K|L8qq1d zvpm~FWS`f=l)y2?URk04wRq$0}y0+k*;o8P3%*1aV z(6AuoI-b^rC^xHDJN6k~-I-`;LUWC9;!f3q_;ISL`;iu zrdw=c=otk**V^?hUD>62Cec`uC-a+$$LZ96O%?r`nhUA^^=-^G2I{IlnFmki!GoIz z)t0tB%ihr9HpS;I$jn97&W?)a6H;hop|x4AS-T>$?)7b!b$vfA%%_FTfvU>rAuK%`4Ut)i9W{*RgjT#JD8H*h@7>HCY2hA` za9+J{Hs6@AJ}j!LuRvJ{XYOSw^Ke&rvC4eZRaa|(Ia~2AQD~0(l0P--?7bw5 zps;wONHyxJZylMByPA3pkSA+;W;pkPGQ$QI#8;UwHN%AI>ziq?i=bM?agpgXb=-+` z&}uWcEP>HFMfX7825_X5+f2nULsW5(^Y@pWlp^g1)ex1kwh9$dHFQTx;tU1RL8L5M ze%EUm_vHvTsh+2l{QOx5N`B4*>s*;iS!uJ*$+l8s7F3m}v+a|`M^#yI?s$rA)C0VD zTMdUcALC^m`U(3Fh5bsFUCJk7kMhj%XuDZG_H_--2R*p4%e|6g=kCysImm`TVIPvw zCE+@?fR4qks_UMXyn#PbD)PYW2EDme#%}O;qloLzdD!+!fL;=Ud}<440Om(;Yq&pv zH-s)tMAf~sB1*cQOxwT#{0=uarbLxQ9R4bwJ54@jpX|$%f{oHiMSb@PaOcs8POOycWhdw z-)X&9iPu^+VnigN0=Et8>h4r%U+JIOD3BO;1nr11)~}##Qlqt{-S*|nOb(kjUz&U| zN$?SuC}boawV`8ELKZNi3`hN?V|s=1BCe>iZtC2Cp#MTqxKUkuTAf)Ws?@oIXO0uq zDcCHc^uM<|+bNzLlva)4=#BcF0dvD(#p2IJDZ;uZ4E{@)v^nQz# z>)%$nQP^xSQ@@zenq1*hRXNRY0UjI;L1LhjvQV1aDwbD_vC4D<0gaytqjik2$*koV+oV!FuN>5@OzY6Vd^+^=42-^ zJ^tDEsk{73`Dv>6%oJbE^_d=AZMQy>IOyeG`c{Mm3Xnj(7UcePwqLDOquo)UP*{vcPe%sDfk6h)@ZQ-IC|#GHIu<9=)|*8qKU?-_6Q) z_RMAy+yIFK91GZ~+vcq!gD?6@tU}8@<89Nz&d!bSDD(8Z125x}I z=p*)Z0OF5sGWaSVW3D93r}r6nbIUD+v#F(F(3Ll+Q8lYZ*3FBlkv*Nwe|qZVe`(j; zyQMxbNB(c`yx1wq|DElfou~8v_wqP)P@MuGXTKA4zpWSUhfa7#Nq|>IZfxA4_^yul z_-n{p4DQ`lruKsPyaMm50H9OrHzq&usTyiWq^9-d16UkuN=E&HF#|6jb? zn&SUkub%k-eLM~Lzm9D8a4gs)p(GZb4x~~X=QAH~@;^fPUuX2^7e_o4qOD3&p*kk* zPR%+unC)wp2D9YA%$d?Nq@=@u#E4A}GPz>dc!I=u6TVS*-8g*w^JkS3S$~HwUlfSn z*zU0~A8uf<`vS*)8Q5u#K3||UA-OLL2|BMR=?VmB+R+;}DyCsZGk-CuRLTGCbyzF% z1J2?9Tf4i@Oa8xCyU(BefA{iK?1R{#FS`{|eqXo_ZKW*RiJLXN=c{GYcEB20Hs)8Y zBMp{eHZyTs%}G}}|1;M=zw%eH129+r+ubSq|6ja((*N$|DFwuo*M8mVHWa_SI+oww z_|-1)(haHgi`t&fcZ!!hvpm7R#DKmc+41R?o3PoBp>Pd@bE!#Xg<>A@joQM+i<1op zE?%ZQ3%IK44&MTmP{GW!4e}iekvPO4AAO(m58L0rGA{&TBP>qU>r#1MxCMO?@~Ni> zTR`2Nu5-@)6*WKoRPz5~*1B6ufI0ks=lP3r{?}LA&!0c>|ND4K{J-+f5J~l#?H8^9 zDM*IeRacwUP_4ve<;T}7H!QPHXmDO&Ul9`i_-)~u?nMUH7*=z2fxUuxFAV85ka$4T zIB@RT;)YQ>TswbJ50w+%Ux=S=cg)ipG&V#sa% z^7xd5#3v%VSb$sjijlk7bp$#1)5=2QrZdf9d#BhJVaNx zRXGMroC%{57Gs=p;66hMo{ULM@;^s6OGA8X1z?{2_x#1HviyI(_3}yn z-^a6r|0k!8Gf5pareZ{6A}AyAsN++HDOYz%WCrAEp^@eFfGN*z4V1BS`Af@T$(6?p zzhdR0DLzmT5GmwQsAWK{54;>Xq_vl(-AMjnj&+V0O_P%KHyf+pIGT-YN0(1RShrwq zz&CIMu>tk_zrMSEtL6Ml7%F-xKlGFr zo>}YPm~eN!{&#kE%KG1{o!zJPe=pAx_P?QyV?!Ow+5hK-MygfEIqA_Vm5CZ4XPf^E z)%|Pu2`Zf1HN&6dDOj*_(XUW)`P!Wk90w}uFY^{8ReQh9hd^S1*_FGuVBa*E|LNLO zp7v&7XxH@xi7-t?Ub*nP2|zXN6rifubI82@YE!{F&Ar+?lBc%U?{I(W$y@V4PqqBd z(}s9zcjk7kpxN^O`PQqKrSsoATU)zN@t^ndG_%E1Lvm5d8M(yD6WrwAH@oCi{X!yYPoh?yG+C{;A)wdPcHHQLkKnChd#mYGhw>;S%7 zt6gf8#5BOu;U@0eswk6+xvz}*x+2eU_WI#4eYpTGX1VcwL5&JrWA!fX| z0Wh7pILi{|H0I`9EMyVOdK*W-sFtQYEBP0H9MK^2xZ1rQP!_8TV2OP_4rq&!XIe(8 zW0$I&Ck3bz0H(NKy+EtI9IX_3mN8KsIai?MSt+M=@#@&LDW#axl=+w!OJ%QnI{Rah zS&@&;L|;%eailhsQ^#!oOXDO5^NdR8j@2P*^zzz7R0551lfKevnxgSbw zYhivf-KwTL?+jkOdIjA8Uv>F7Y68;T&vvZDG?rYAXc@ zFU(P_ET~xN*=p`JS?{IFJRQueYQC2%)=*_xiJ79ux=~FNnKPRkmyv-3pk55vpj*W}wc?Pkkz?m-DJm_3uxqJ@X?7D{XL?uXZ$4GF%IAN~g$; zzzXc$uQIWyp#aOVp4%`Q9AA}n9X++Lk1kI3j*l)64qv~2Q(9pveUO<&79%z>M>n87 zL?OrJJ0k)-NJn+&5Nv;^@6IpYoE@J3@7v2?-o1T)e0X_qbXMJOy8*Lm&+Fv$-Py(E z$@}BihckMabHt`Si-k2}qZwdt4)!kgYSBC$n|6Q%Iv}hLt2JDI+V0p*rQ*#`FC|!nq_?0QGe5{QTEYI&xz{Z|5-y>deQ)6zbXwTQb7oOG?&*m*B@hh0S z##2^0Wa$oA_=%UdS+>Wm-hR5+fnk`YhLBw0>~^+lwY5;B2rd4UZ}{ZERk+!p*3|~a z-8S`d)&Yv?o8ih0ekC`>RSmk%@wAFTtq8(tCSx*N9^ep7sy1YbbXHNi++b3>w^M8? zsovSqkI4x#;3nY4PsG>eXE= zbAQX*0}ofP`AQq2@)F68Yqjq#N|Gh)+=?bkZ7oZbrDkqHm8F($MV6(umY~a7KF?*8 zlX=p%=qa3|oIG|PW+en)qjz}5uvU^GeRgj22DDU4aXw?>D#%IJu>dbHp^xHnnCw&a zwJ;4(w=Bk%S$w%4{uhU@RMn~sR++9@@B5t&H!8!yNq~K1Hw>!9*W3#kOHDbqF~Y`- zbNCuU&0HP6+MK60ImPZ)zd6cNq_`C7YqqJnrK8$<>D+Ba#C>?uLwTFI&VUI zR>jO6z-Snfc=TQs=|P-^VGCyR^V$i}40b<6B)YIiIqU(dasV?-Pt#QBt=rL5P`Q2z z@wYB!=Ufb83+n4ZY)bjcdw6GWO}nAI07zL6$22I584XV5!15|Z;PknuDvx&s+2*^q z1(WrU6!WqrVXkAYS4+vCmEqPk9+cue@>Q4W&puN%@BGi*=YM|j>cv$4uif3Pr~9Ao z<++!WJ+=AXRdU_GP&Sw;9%@E-+mk(}na6mvXZx{>&xb!#WO7J99V%*isK`mWU9}8W zaI7eA>FHci2`9@PEUK!!%n=*Q=@vR#RIYT3j+Z%HR8jTmbP@a|PZ!Mx(>Y^f=;G;o z(bM^&Is2ba=Zl`s7d@RXdOBb9biU~6e9_dX?%{mV3{7SFfYFS?D>`9ht+Qp17|pN$ zbjIlEjL`$1Fh4LcV9ww#k4}y*4qjjGoxM4)#rez*7CUn^ z2jpK-ctBWFpnu(aTZ_#ZV4F^E1-QPnc$(xZXI_GPzJGRfdU37}Lp3_bQs2WO5N0Pm z7K3tD%zUq=i)E(&5uS>h;@>qH(d%*9^cA2t1aleMt(u9x~7BqJ+T%OJp-r1SL?YNs1CNnXZ*zF*TPN z79jfy1V-VGtXt6O`QhH#{?C_t7Z;702V(yC5(&|mgP1;kI(mE2oJFZ#h9nftIo12K zx6N8lSy&?vX8C8Wkog5MzJEOBC!Hn?FFz7dNfvS0{XVOQN`dv1V8DKPN-*G%$y0)X zrvw8}2?m}L4E&7}4AdNq45c=`e|~Y!P3l7D zJ|l_X`N_!2&vn{~pK?JX)t|?8(Gs~9q7mu=UBbRD0H}kR7XZ`K`si}dR@9vh^WQf$ zz=Gbjr=%EvwUDN-;WT^$YP`qX5N-(7g2^Fjt1g!v!Zf#%^bqB?n&yWngH*bI)ESGN zwc;g^FhAtVwz+tWccJFI$nKhv+*L?aUHg@xQOyFQWT|hgVF$e*)hTAeZ~*P27$^%c z13^KLz|I3qXbLgLf&9c1?33XH5M<4)SI0@HZ_b&4QAh_U>=f*;a`}z+@;e3xdl!3` z=kMO1?H^vgKRc>*vLuYeVhHWbMC|=%(EU&TKb(gV4e*Zv;d1l}l9~f0#&F!&aFDsu zJt}uBr+eq;54uHF2Idhv9GA%pT%em-yOfncx-N*j^FWP-NZypQ-vA_9a+TlKU8k#` zc?P3pJ1g{MR5!V-`B>fBEv(4u25)6dRyXUQPbi|i0LT> zVx0=6{R78FUzzPhs3PSeoKtGQUQ_SlaMA<8QrDx0*ogPwL)$aPdFPK5vxz4cRC^QJ z#_BHgtdg#@f1f|x85<|Hi*jn)_4|&;%t$<WW|TOo6@W+&GQ; zP%|c9=2V}A(1C9%e0$1uu=+HTZqxJdw_e+~J3!+(dP=1Dlt>X8Wl#M2pX&UNSALf4 zZ3PJ+XXk(1+TMD;UC#gb^40cJ{>S@x-29JM+cuk{PJFaLCdc|rfnRMdN6Qg)BE$VK zm73JiX-mpI^LGzUXe@1g#}1zvxR@*!rzj8`VWF+`srHIl4HrReBV0W+VLm|ND5FojkuADIrPhYnBjGioQu0xXL`eQ)zTK7A?E0T;*m6S#U9Mb^H zT6s(MP38Eei+`?9EWC0N3|T^4b*;gj*dulPe?_ccJ#+Z~&huAg{r~0G_7nfVm*-A6 z``?tQCn2m`&b|TP{N7~NA5T;MEv|5&$n!ecm8dN2y} znEwdHXoMrFVDSkTID++eX9us3&o{K%hGM``GDe(mpfRYx9Zm)$Bx0hJEgAzCC=L({ zxaZR-35nF{I<3}El)=w?n~*3`4;*9oAjX6P!wJpd9uYB42OXbASxliaA9zoE#~k2;=wzm0*kMHH1_F2S~t&;-H)0Wuwz-T{yi; zd~zZvo2dB?^=W&vdl~@IqyIS_a5ZDs7_Lbes`x?@rx+4OuSh`Rkt)ZfO6H>g$36x+ zG`B|aP2g!FmC{YX!Ur)%0Uf z>TB~1F$`CjO@Nb#gotgzzf&#%1sN=tX5^P@j0*ac%a24rkTP40xLvIGExP_&;p-oL z^&O)?j>eEc_HCop>i7Et#K)~~K+d-5DG0zc19n0*_B-aaJ2AGZfdit6XS7`?!QyO; zuW7n?Uasuvn+ayOVJh!P;9IQ=YGz3-*H45KAyIRw$9gHrJ){%0$n~N&)HtSGaiVho?=b7r)QFPglgpos$e#&N^l$wHB}EVFdPtu zeN(JoTs?hwL&VSNz!|2b(VXTl4K&AK{3C^3{;4NqTT`|ghNC|CeSot z)W@7lylRF=H0CtKGicH*0mpz;m|k0hqgXH+==CXi4B-aD14;!K3?&kpO7T;{uFSul ziEzyO+i)q8#9no_NqhZhBe3y15o^2u7Ml^Iou8(Z$N_lk7#6N z8|$=MmQ!j>E0P{yNI4Glf>7O~Byrg9NApIFXhS1C2U?P&BRc6&ug87eK8Y=~yaNsv z$B1igDKIyrb4}St4a3Qsqm%y!N3lc^-vl2s;h7O^!Z1v6ECRg@7>%$P<5WpxK@-`% z!pG}>o}Lv)kT4o_2vs6uZ4FKtRtE(+5tz4H9^`0;?aubjcP-B_md^I}cOV70;!&L~ zZ-51QF2A4tq6LN|KFq9CuuLS41I)sS;?ePtA(t_MOB5EZaLc+KAa+kA}pu>_w}_T z`>_EWtEB}Y;exAX@~>K?8q&0_;=sx|SH^W(t$)C`-(Ha9+i!dDmPnx)h89)aERnv# zi0n!)?)NdNLcfw8_ER^&Ar?x47m)4HekI;bwY^(iDzlEnueFZDxA)&kgir64nl7etic!Gmn|~^B$dbn;&SW zSC}ypU=IC?${VX1?}DSdYwXy3-PPE!2c2uMlU4p}VaEoL>mwcgVvlMUf)2qJGABi( zewK2ra61UJ$B1|VgQX7c2zz%0ZEa8$sK z_1VpAr~*4m<9UqFP41w~nd$;)a0U|njFzH*gc z+YRf?yZiBbX_45}jD6+*?GMclvz!B}_{lY8AE&8pB`a$hJ2Ujd8#pSWMc4KUn0O>g z;!3o^dhAr_ht09`@Jgbi4Mrp;0Uj)dqnX&L)DN3u=i!w^i5;~Gx8%~AiJeOQusL=f zUP-)x{$6;ECNmYdS@o;2Q=7H64>zE)*B%`xvs_2w6tJ~^J;pH%k*{_zbtDIg;Q@m@ ze8XLW0ua3ff+s6X*u|TgyuLp>|e2JKB*jhea9Lw|qv76y1k;#dbrsHHZfEYi(=;J^2}-7)3jLr)Ud z@7)37IEX*|&j%y~BZE}n-#`4%2kz5E{h{yw`u+Fcf4>c*n1Xdx*wGg$HnBpFPNk{K zFz1rN#)gt4cJyLX4Rw|VX@$?~u#;5m&Q$lPPB+DAG{DS;`8a5i3(Ti+jD5A%9`sou z?WoHv8+kA^k5Scl-?AHtpz}vTG5NDP?1-?L&Xi+kJHyPy+jGFN&n9;gg|qy#!j+{i z3~fX^xqrv{W^|aY+WJ#`NGpVtMK%cS;6xT0kT_Tx)Fqx3t}J!g>0;PH$)iTjN@Z4d zf9D+U*Pa#0a>Nl5{us~*k(ifoWW>B~cxec4^Q;^@TJMx@!i*NW5!T?(<5siGkA&F)3NS&5DA zVkhNujIE03rbqjjv0cs`R9*ctcIK|FS+QS_?XlQtz%Z)om$9P~L7DManie;3Vhqrs z&Kkw-8hdox=n^~IxAvSCurrDMF{3d}`96&zQV~@t?JTF5Iz&PQL^Qx1*b75?jRUh8 z*MmNd`!Uf#9jl&5QxCi?xDH04YjMOnhwUHXnqTwZN|CM;%U8v;jn!is_l}F>o z=70sKs4^Wo@Sfvg8Y)STf-4mJI4FoVj)frc=-!s#4fLbQr32@Js8ZNz>QXj6E*b0I zF(!yAJ4W2N$pkXm_D4J^1c7I#J-wW%UXO`C24WAsIeJ&s`2%g*5PhDX42PJRla^Uw zxu`3y_plwQ)sO{IR0XIP>pPS%udQlHs5pz$pwvXGTT*dAO1pFnH4HRX+U7&}*;b-l zIw&@OO&uq2dz^8wKp)MO1W-vDM8|XGlyJwv;x}-~P#n+*wzt%=K;$b96DDI6rx9kv zHxW3OO|Ua{K&Pt5DeOF24bvIhQUp|{oknI`E$uwEXT`KLb@Z*O$Ew3{kMmhE-;vlk zATv#zDeOQ%7z}9`Jig_11O1B3H+@)FnJRRk1maM6tN2E)AP9yIRxcbuYM_*VYXj5G9uoo5;mwgu1zP zb7?^CsucZZ!5%31N$6iY^`#2M2_gZgNq^j z&Ysn;tfjbeX?aa^;o{<5ip4H7x|L&Re)zMw{#T)j`OebBQ^1bBxnWMwLIv$)cRoC>XQeC4xdvty-`R7nk16einU-|M=lj$8>S6u- z=ZANsg+r!yJpDHB1N+w!lCb8B=b<>eATp5cUMj65bmBibD;zjVi=GKR8SjP z{K}UIa`Cf@fi5>X`0)F!>MQK{V;rO*X4M<<)8T8QB1Bhsoo~P?4c7U+VeAI_f~Cu= z>Lu*>YTGAJd>NFF7+cf?Q7e=30fFgh~m`$w&uWtmo3fsLKy#O{@|-XgHMm z@0`q_H?U{QJ$?+W5<7y1n9XzVzztkv7+82Mwxo*xN*Zfi*t(4&GQk76SBZN{`J zGE$CZVlM71CRlrPl(b5rrLd#jGsn`>t}{a&>==_l8Rm};H+P1e*&AAA>^wreSXftw z9Zmx5qsrw}+xuP+sJllEwu+0)U}|~jZu{Hj5#eLhLj%v6iv19t=M#VL66&8ktxN+F1NS(X>L^X=Y+Ix3L};F$UI^VW*ji zwUmwZP)lpI*lA#5Eox&usMJ~|b{d&ji`!TaE3;N$V`=m>GqG-EV?6*{UX{>lW@6pK z#=3thx*P1wb}BD!V?7{SUKMtpOf0vm#EZZzBC(RoZy56NDQ1KQ=h&xlFoh-+;x0dA zrm6;Eemr3;!Mbwt)*5H{`4p!QA*Eh!Uap%8S*!b zrZs14Za{r>ucgRuqTI8$>J1U5K&-bOqGAEwW zSy?)+9qDTJM3Fr)VOleJq2h1?0}Ln?G(v*->S8l}v{whCmS89(G2(d1Xy=t=yv!kU zL96YtB70a7PmktVO)gn-g=5V5DWikAp}05jt4i)Mp{Y*rfuK-drzZx}%F=+XD@*qHGra~n6j-HMN-$G51Sz9SA?Oi3H?X~G>?mM4Ukp2@ z(tPxog2OZ{qR60awa*P~->$y)V4#3BRR?cOsy9a)EY^QS>flNwdI>wTg;q&sWpy8; z&{_%KDPd=}(E8iStP*x+3$4GM%qn4Lw$S?9$*dA~W(%#qoy>}`V8ow!x4PToxj;++ zlH-rC7~_;nyZ<=6+Et;P4*g&fN-B)Dd9etxQM%p zXEjG_sbP>wjIYO-O%qHO^2En>kIKdq-1Bpz zL+??99iR~hCzx?|{TbL{a;z2JuMg_O@9SeB65i`}i|S@TeeS&KhDcy8x`hPYT@+#_ zNIbe!O?5Y{Ew7Qd3H})SAI%Md`a-xE@Fx@{;bQrnR&!|0poc_gggXq62p6pG6jC9- z>5;4fw#M>c+|hFb{eb!(G3yYzv>95)PC~hqTGjZez>c~${4qW^&@U&n@~jQmC`fV| zI|7Yn8|hQn5$G{oI8yOQwtTVo=JI6k_;4BaT!Eb=4MQDq(3Ey$4M-kgsLBoWkA^2y zoHERDJU=V)RQ+n~=(O84M?XmqT9)rU7Z3XiUlIWHdDvtm=CLEv(Iz_RQAdK}M+T^J2Qb7zI* zJ~sTUBqY~jr%{;G-!8nV8ap*R#(%r5;%e;F>=^&;wu-B+6zPE95(k!kqrLGOJOT)8AHRH41b3+sdp)VNQQLnUy9168Ky& zB=Bf{-lq4eIB-+`QF?ata|8X)VxnR$&S)5tc%)%(H*|&Fv|83(qtkoXdt%Mi5RwY| zp2x9(k9hKMknpWWH4f)2(wjDW;kpfK!r;3vwgw|ukJ>i7GSh-ipg-+)^Z zqKU_Fh!Mx|TMIz4`VOT1POG&S zsxx15A%>GpK=7}<Jmt-I<^$tV8ZU;M;F6Nb>EsJ@^+*6-+7DcU{^I;15nSzppz#Hd?Ke62}Bw;)kT1b5SJkfAAdg&CKlFqd4Y>!Y)Bo50B^CPU(**h0-WfimzJQE_4RCEwdGUQ!x(97K9Jb3n zRuzle(*v|4(_B;${uoJR#{sJWj^i{s&uipE-73LVS8h8)NqywB+jl7*JD=OB5Ba=a zP-m5d6SYnZ!Kp>EnUTF^#(mGT`02wiL?eA&O(4|)`903i7*n~pNUWKZBO!Bs)q%Z` z)9F5DE=S7T{+va~$MibZ656nbea!{kn?E!&QX_X-nJ%H@NJ#7;BZHG0^#>`DMC|sD zK%-oGjMG8KY*0(t!745o>Plwc-Eh?=XR9?ESW)OYWq#b=>1=hj`g*K+yZVOuZsERq zefc`#h9w=N#PC8Twe|3joC}^NlC;$RmELS3f9hGe9@7w4TvjeMks#2sY__Mg4nm== zy6u)gYiC4pP1#2|_UtAkBi=OIH32&+9hkl;D+AtgPef42FP&EFD9%z{8ANalR7GR*9C|&SSm^i@ogSckHMCUK3?8elpeJ1F2my^T z3?{NIkqxp?!@2I*A=?uwj3%nqkWOe~Zk$$)h-)b=@fD#ea!tW+R_&h?J2KMcRcc7|lLOVV1gY0={4XxuPD$V`mHo-r<5#++SrPK}?q60{B+ z#XwnrO$rR`r$Ud?)K@XO6NpkS^tAgrACYqR#y!vT(EeQEb(&JA9nD^}jjpAW;hR7{2xwFuRHs_KU0eut0yoARGO z!oK3X>d(&T&%Rk)=O#^L#@u6!1E|je$tm+5WnczbJOwv0)OFK}bg< zIa5{x-^hm@P)WvD1nI%&Su8a}a_8@yL`gW2?2yEEg=T>n$oDA=6gg;K?i>!xOG%3F zC>M;>jazF^#Fsop)sJUsbAdBD_9_f!7%G>dfW!CrWc zCR}|}N@Gv`X&aWWvKDj5Pt}>O?VaZ@s)4rVMN658j}U5QIY|OEfm@o4HOHe#=E_rS z$(U|a=kTOv`XM*yY66lsB&Wc6?Kn_;6V_!b@Wb}^uQqJ1H`%gOd5s!ORA1OvJ_*ey z@^<9%@@W|A&q`W5to!5l+@7>F{F5t?)L$ebAyPb3i*w032G&r*@WGfVZd|=Nw^5MF zl`_r=ymtN^gmlo=6f7l(avbYGY}1!p+WVL-mChszHw=r(&~Bt-ew?BdPp5FmYGaqyj2c)qJHbyMDLsI2}S zfSw1v)$?6tyEn7it?Z*vC~Gk9a!!pIFCTl2tEw6mFRxnPRiNd&XA>?X4cwGo?U;(o^;jlVm?q!C7&Fq#Hj2bpE)E3?hXIz>tX?KSW9zuW z+U3oJCFKtMOt0~ka*L+}Q+ZqQk{z`Om4+j^m(JNI|8y{eI9%f}EUNEHE2(PFw)HWl z*JT;(=y;n_sgY``7Hx{NXb+z9Ti7#1`c<|jl@?s&7P*bw(@es#0V_7{1m;5i*Rqgr zcNVO$)3icmv21@Z7;Mjjp|WgE_l2^GT#kk%&#C37Le83Jrj5i>WO2+lE4St}#;11m zd>Ua}m8k8ffDDJkPeajxJ!#Bj=9CG$(!2K@Yq8}j`&t<&%MM9(dXnwD>Jm+nKdbaK zu9V?)jV7B@&{?Mlxaf*n|w!>I2V-RsqNta31pRnTp!|K zRJ7`g=o2Kyb{v^T+G(}2K?u;73BZ1cNTfA9ps5s8V^uR1NO34J)t= zTuqaEfpuoep^#w|7_CDAPGTTtKSE9Q5Rp((>Lm8nzXHX982*6^^66<2V_es z79XY~WeFf5VFY9#>^d>a*26#l`4Nbk0qt)dV&XQkrS}kdjI>26ScV5DO$$xe=@hPX5@&SpI#ZsCV z$5Zm=9B7Z{0gfl`Dc3Adi}vO896t_@hm`r)#r!1-!%KbDhcUFWrh26)42eKrWSL;x zqqWxg+K#d446s0*BDRZo{PFW|ZIjjZw{}nd;8cfF{MPRM)^^7ATl>owvyJ+z6hcLO z7$k3Isc|i%RyQqZ>i)OHsUq2zFP`t1Nl^Ak!mv-cMaE{;EEe(~|ELex7@n5rP*;UC z1~b)Ugk#K<$es+0vcyVxf{r>XcqFY?vw-1AMYPsAkEJY_s3V`o*1(#H&II&M5y{}Y&x`w5m!^AxLY@{gN{m_$b*dn zD8Vb2XEjIiW_$vSOOi)6*9%VXYT^b`pc2@J)554^8Kk6cWyylmFOdQ}BJHisZFSQV zWARC?#6(4E7G(5MkXA7+X`ya{*05w)c;`(NEcHZAGYr**yHyIy9L__&QPb+k<-vZC zW=Sx~Nm9c0Eo9=CT>jE5kpXNr=rV8?tLSnO%rc-fFcW*Di)R@)OQ`^DQ>=r3CMIkvyji%LE zpXcvX8h|{;_8GG)M(WOdTzK6h)iXIaEg&g|O|{Kn4U`P6l@tqblZXjam-fUvnGyu9 zyRHC`3xWGhXeKycbMf$PkcovfwoYL06URV{8(;|oX;?9%O6`xZs@0eSZy)nIqCN(_ z*Uj&g4^I0*^0p8C>&J8KX7e|W^o_kfm)U~9tA4Qty4mT7_whWDzG}6OvW-+k3bs87 z>qJ}29NA|%w}&+vG1!zHHQWYGAZ5R3+$Q5D^k5k&&;L;mXF3F|tAtcQwX%=M07#xRk zsupPCO~rL%8?H(AzHYARy5J;+@Dto^3Zq1pes-vTyT(Y4drQ`f1br!OntJM;^}4o;Z+67^uW_7gsmZ?xpYpA6bd(C&px) z>V()n2ebpmp9axXi3y17Fv)ODEM+#ODa;1hqewL96JV+LN&nQ3^DyiWcemdRiOa}1 zU~=AAWmS=n*amD$#hokw+i+xsaqQm?Z*Xnokt&vxjDrnS@+4|XQHc_j<{{yx<&|1mXe}l{B z!$Jv;iC>);Y|%U%4!UAUdhOvh%sNx*cJEbB9MQIo;|4uPkD~?!wQI&2wOYa>Rx!zE z?%&=Nu6{{%M1RedPWkySNRU^wJR1q};ss;RjW*RG$#Cf6yWIza7cUA#IO5MOklwdP zN$fqG`9Wr5AiZIlMIXZ;`jn2uM>9q5?(SUGt4`*9InjC6kJMe-AHMl+r(Ya4AMx@Q zomU_HVy0x4!;vaWf?pp~Mfrm*qa(nn%KK(ordyZy3r6Um2$W_zf%6riTF@;cCq4;# z0TK;9BiuRfn98PszE{=>ZnfgYi;2UeBq)!t*Ls7%!_{>Fp}Q8X=*uefTOvC`C^klGI7=FqN+>Xza&-xo@p) zRR(H=cOP3^{%X&`J-h0fLP&L9KGr0smf>^d0@1sKN?Ynoxp~8!w=Ods$K|$`i+S;) zKA1)){dXAFhiAZx7cC(^4*wy37+;N#N1||)W0-q_HZR`fLf#h%?ag2AA6$)(;L|H( z7%5JXu+0|eND32K_YZ(QhjV|i%T=X5m-~z1u=lculC{|vrU>>1e!_tmm%%(?d68O# zauJGhq0d{XBjix{bHi+8BxD>XT22=hq=PTZtc=q6h?TQ>=3c&w%J*rWBr3}K_9)X^ zWXrJg{`~M_ynl3h=zTaEd*_Gy?@!Lh$H!Yvn~hGOgmx(%#TkO*_&NF@YZyhU>2y~t z^(}30i9(|q>IWj15)5zIp{@7IRaLO;QE1WDY33Ygotbmk8#9DA!$EJiH}I0zZ;XD~ zJc0(MO@Pl(&x0uy6KP-dej)9FOcz{2gM%D3+$#A>(9oJ<^TrT@y|Ner(1qI;0uU!U zR!O+3nBs06mPDZR9)^oz7?g#Nx`K1jKv-nbzYW;RXpXVLa;f}4W-45PFnn@;c5yX6 zy&4G%z{ka65cu|}Z_72*$lwHBoTuPhiz{_TqDDPy9u;TVLM3;BS!OsD<|eW(?%Sla z0T$43cx}}4BCu{5XDc}LaKz#s$Lm$iUyP5B&wjo>I61%iA6xd@6(@2>vqY)gtX{%7 zXynvMD5FoVBEwj0U0+gEs}nloR)so-Ruovpm(J3#@)D9a$V$3CqZcV$L1E^aoQPc^ zXTYs9*4F7zC2qa}#OaZ}4JIU7Tzah|wwTe;Tt*~tu&UZ&JM?*PmWLr_i(RNN29l^{ z9HKExXF^XDZieQqGsZm=59o+@aGb%-jD+A;%M&-)>TGpWoCFd@Ue$1r;(ATjSjj`l z=x{<2ChI>ov7u|$`;_ZL}y(JwNOSH-02ZUovGZpgz=?wKXte&Gh3K$c0^XBs4;=|G20mj(8p;Li%gWtS4 zJKir?*p^wlg?iADgvsx`cKRvlhBD2XBSuznJ$!w7c6D&s%YNn1;-!YNzk$^|O;#ew zqX>P4MWbOAI7oUs#iuZ}j#%5fNjga+@s{12tiNI1RX$O%(GlI_J-VbS`Y?P+5 zSo>nroKPHsU{8yli5s^sutIOP)|rvjuWBm1AQLc-xN({K&*01%>iHby3U@b$O&ld$ zJJIY`Vt0Bv{lX3euitc8Z6z~tXCqTj%RRJ(RA)!E?Odqv7Q&r{!h2t+t>kGANm z0vw=5a2Sg9)cvn!>edBe6e-YT?l?b$dMZO_%)9I>RFJM13RgDXEII6x#ArFeR-xK8 zl87NdiHefBO+nV_LM>%sEk~AR3|1w($1F(F%w?X)HXUIcN!fdVlop%#wj=yR$3;s@ zH2y#Cd%O}K|7M=5M7+N|xTwE@_&|~9P!%s2s3DVoGxg4TJA-k&r}K?# ze3hBlJA#cc6_Y#&{Zz=g1Yt$2cO(uXH|$UEc@_Sf{>tk=T`UPe;W0ede+pLc(E1;| z+^MYp-PgN==k@Ynq+pBLEZ1odjsOxq|4Yf%$|R^8XRq;*Oira%A>GvnI&UmvFDt9imh>wjmkU0(m& z!@=v}^ZNf5zyE1opo|15N8!;)5rl_$fQj2A6J-KL`NA)v?U5FAqIG7BJSMR07p#h} zTn>SQ?(rl>%rFtIwj8&E-k&`Jf9}s&|IJ}HpP-KW*Z%s8bi_FtBfAK=zc=n!_2VwXd8_!^Zh=K{{F_w~T_DUA2K6TVk>k}r zzqhd?x^rRDmXbF#2Y})fgso18qYjK(!a4Aa#BZNl9TPFi6fqLZmHGQrE>-b{Q{)Km z3gI~~|D;y*)PBY2)4fopDHMC@TqAUNtv{eKBX>F?QL~Zw;wZDwX9|zi5;m#pNZ1sr zc-L^mk*F8b5+alHkr)mUdc(f#!-1E4*Ak-Bu_+v7HQ*LKW`AK6s5U49*MxOH;1BVu>n!j1dpt?5o?bCCyZH$|f>8HY9Ep(*;N8#0g( za71Wvl%dCbD&CDRf5e4(IzBlPzMA9|QZI-w#DuRV4lV8jAM@KDR-E=bP`sxjZz9u$ zXUlB~s>3KXVG&(s37eQ>nShWGt_nWpi0}quR3MNxk>@RC5LtMIfoi~uTd>eN!o?+x zDm6lxkF1Rfs{);{z!parbz>a55q3JAx-06)1jI&bK3YpzC}SXT$$65za560}jKk`P zU1Fj7df|2Tv5ClserU~1ra$vCW9OHG1cR4t7F6F8~0UQ7FFZZ5jceY6o(Ut>^ zp-N)y*9N=fJWnvyd)@1K`c{fzhGyBK7?2Y*7HLCj5m?C2LzQq)_cjQCpW3~e822Sn zZ;FMu%)=~*L*?0eNY3jN6~juR{pVc!_2u)sSHB^Q$4Qs5{swB5Q%?vJTVW)IgTb&> zq9xubT1;5YF=bNv{S7YeGYr(@Bu)Jm-{7hmw=-Zius`b=4K&55vanTyEaf5*7tYvy znA^DO*{n`rK%1VY`YCa3^!Non8PPD7l8)4gnj`(-rR$uSk-=#3U2_RDx|)8)ih1jj zuLvD=g`Um2?EiBu<2YQ|D^kBAUQkbE*dWcWb20g@_qw0tQ5GzfiD7ytL-I2@e81=Y zJU;f04?Y|mL$lZ9aKYMsFmTf%lHN_JpEn%r!zVmv*G9Q3J|Lgs$0dH@32}CHFaqJW zh#>KOnY=9_*5`P^GABWfC9hl9&m6@ztW$#LGj}gio^91u=$FS-<5<5|*W97CfI2*b{PR%a zLM3;SW?qsR6}-$1!MNdM@* zvfaVU77C;PcxEh0oo&?zV^ly!EB&*p{?BVJZc6pI6}6$T6OZHof;QQa zXisF)CL$Hz*uTWbe^Bs4vzI&R8(37J>Bo&j$Ki5mA7qm5o$%4#WW!|rN09jwA(MIf z2NB&J_#>=soS=Vs>1fOb9L&vO;`Ua8A7zP*QaLrAir=icKg$rNN_DQ5%$IIwSUOmr zeCeTdN>JGPj7m5$o70NKdDaYz!OV|r;R;pSFWiS~RD8&_HqAf+tcXA;{#2<~N+(q# zpEar1=r~S|z%$7+0sB9`cx_xPOiISeR}YZnMKdKBK2j#gSK06I(C8UUyq9TjqCB$1 zd$U9@(ZZd2Xo>f-g^X|;G6?pfAZSsQlm|}6pZY|kaF8<37Ane&RJC?`;@Kynm^}_o zs#PeWM#k-x9WE+wJvjLs@YQ`2PsH;dC$cnQDwx}<137IPwOn1p-D@hmmo7%)Uu?S)oxk@dLDWwd=7yRsv}o_^A^}LRUD{Wj8si^ z#G^D+QyooJoTYteJ+S4pA8Y!*e#ev==*ZJE4<68*bRHZ+edj6(w0}vh8;O^sN~?!H zgRes$hNeL;lPBRwwA-kuOKw%d7cu7Sp+7M$oOvmq6&JwN_D{`45c#DGt24noL`o*p z#kI_`#8!uQW&x~ab8os(AeG`p7N+jup^|(um{x=uql6lfUf^7(zz5yevsDZ|rUdS+ zD>ynwEz#6mjl`*rlqJBcotN9KvXK^*9@}fuW#q$SE&X>gl+lzO`A^MzZWGc+ z+AHlO;g3_V2p4Yd^*d0a2kn6d7lumH!V$OroKcA~TDeJjieTC)Yo?cSh0By;X!zXL zD-&lBRow8Gw5UR3aqD8TjG3Eo)rKdeBwscXVDEzMemW67%zM!iZd(veP*_i^q;;uD z`S>_cm5yKmJ|T5Y$tXK{6K+63=Fn-@(YYZuG*%R?r4V%V=wY=rI`MjrqNzcBJq>m4 zdyF(q;B2ezWEl24iAu91m}VXqGt&t+-L%}OZku~>i7mar&<|wZ_QY_|qyK@Z!sb|+ zWVLV=o9PxXNB9T0zRoj>QqGf0MV}V88HUklM8~m^5C)S(CM$au z=cru$o#E>({^Mwdz6>))V)?uhZ z+ymh(w@~snpe?9X@=fA}bW>Pt>hb$2Qg9B!J34}A%Xnt2O626d)VHk3N2Po*ygf6W zs%rHD{!r9$gT^AUySpoj@d61|l!9hk#uJz%j2PEAn(IXQK#iNa!OM0kNW~3Bvo|fn zO3C_q%0SvoU0{e*B1Cq#0S*#$)T!rZ=;k(?{Pf&8%C zFg**KwjJW1f7r8zdt!&$S=+KSpHREnZEQvbL$jfVzr>lwn)!t@w-bj4h z@byMlY}hY1?&Tk}7@md~xn(`GqDEF^11T#TiQfs1jq%ZzK}EKD5G6d-%Q#1;BeDJJ^+|wtrg09JgJr8#%7?}TIG{9s21f?Ma5*oVt*j@`qHraS zDA$`!(?-#NGR)jC!(gX(^&>KCHm&^4WiPc)iPR7y)+0nLpXn&85jhT*^RaQt1uCtS zlC6MZnY4r?QWx_uDIq`bU=N2cT85pD&=I!+4sG3XAnh>3;^NE=Y0as?qZO;ZsF=-a zV}-BaL}YMRNw#H)RLV5L;lUj}Nvn~gMgst!>>T+bQk@#pz`^V=BuOcuCDV<2r34>b|sWjzC z+R_j3Do*r7J?d!qSY)lK$g`DrR7t<%e2jOtTIS_*xNcIfuov&X3gyZ*6A2NQO-Dnx z2UAeznZ5a{B`{NLo~WxuqSA#9{gHT$CwH8Y&fQ_FWn27Z&3X05w5Bwmoa`SapH4EB z;0c-THEN{_2G4?xV_IsmAC(0ro`rFOGKtD@7NpZe!e?!$quuxlo7}z|zS*T0s>TJ| zjuRaPcE!{zdcraAlw>vohoV=?%P?8?g3v-b)t0IFDUI9ZqOl=mlwpV=tv%RIY%7AotIsLov0Z+ zrFstUl%ax|_+vg-4!ZhS&h0xO=*G!N#Pe$hJbfL?`EOj_+xBn26`p_X22Z`V=!wst zJzj_H&!R70z8(z3_lWE^^~`{650}oJu(CB^c0F~g5 z>7i$Nq;()1^xl-cmbc&39Pui?zt8S?HFvyy%9ZNa-Wg@e3*de>c9-Z}rI8BlKwX=G z>Ohgv9Ak8FS>A0RJ!w2bWFL4V)y9^%s(kD; z3p^&4vsGm#6TRJ?n4XUsrcdI>}1X70{t!$bYsL;M;b#|Z*G_sJ=o|9dohFy zL)MM0wxz7eLQ}5aNMpuEOD8(!A-l7vD>8zpm*1kRi@H))f|rB zA0FIa%_WeiQVoy*%WWH*QG;9C6Re}UW7~bMfDOB9)uZhR+VqtYHi$n~$s`Rqc_R{@CMZQX~iZJjUvQm@q-boO(M`GCY zME=^w-K^o+`zXku+{2)4AAWAR{~G#PwZvnnXM26YP)>1SKesPAQUkntkG{TfbRQfl z`}LaPsx%(JPCguq?KwMD2>Mxci!eHHFoDJK9oi}SF;%m#vY{~vuv7Xx3iRrWBIcwg#ix*S+mN|QH+90-K6`5fL}1^HEihb*>ApP0M`+wI1y@=2.0.0", + "PyYAML", "wiremind-kubernetes~=7.0", - "numpy >1.23.6", ] [build-system] diff --git a/requirements.txt b/requirements.txt index 188217f..84c02ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,64 +1,79 @@ # This file was autogenerated by uv via the following command: # uv pip compile --no-emit-index-url --output-file=requirements.txt pyproject.toml -alembic==1.13.2 +alembic==1.16.5 # via chartreuse (pyproject.toml) -cachetools==5.4.0 +annotated-types==0.7.0 + # via pydantic +cachetools==6.2.0 # via google-auth -certifi==2024.7.4 +certifi==2025.10.5 # via # kubernetes # requests -charset-normalizer==3.3.2 +charset-normalizer==3.4.3 # via requests -google-auth==2.32.0 +durationpy==0.10 # via kubernetes -idna==3.7 +google-auth==2.41.1 + # via kubernetes +greenlet==3.2.4 + # via sqlalchemy +idna==3.10 # via requests -kubernetes==30.1.0 +kubernetes==34.1.0 # via wiremind-kubernetes -mako==1.3.5 +mako==1.3.10 # via alembic -markupsafe==2.1.5 +markupsafe==3.0.3 # via mako -oauthlib==3.2.2 - # via - # kubernetes - # requests-oauthlib -psycopg2==2.9.9 +oauthlib==3.3.1 + # via requests-oauthlib +psycopg2==2.9.10 # via chartreuse (pyproject.toml) -pyasn1==0.6.0 +pyasn1==0.6.1 # via # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.2 # via google-auth +pydantic==2.12.0 + # via chartreuse (pyproject.toml) +pydantic-core==2.41.1 + # via pydantic python-dateutil==2.9.0.post0 # via kubernetes -pyyaml==6.0.1 - # via kubernetes -requests==2.32.4 +pyyaml==6.0.3 + # via + # chartreuse (pyproject.toml) + # kubernetes +requests==2.32.5 # via # kubernetes # requests-oauthlib requests-oauthlib==2.0.0 # via kubernetes -rsa==4.9 +rsa==4.9.1 # via google-auth -six==1.16.0 +six==1.17.0 # via # kubernetes # python-dateutil -sqlalchemy==2.0.31 +sqlalchemy==2.0.43 # via alembic -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via # alembic + # pydantic + # pydantic-core # sqlalchemy -urllib3==2.5.0 + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +urllib3==2.3.0 # via # kubernetes # requests -websocket-client==1.8.0 +websocket-client==1.9.0 # via kubernetes -wiremind-kubernetes==7.4.5 +wiremind-kubernetes==7.5.0 # via chartreuse (pyproject.toml) diff --git a/src/chartreuse/chartreuse.py b/src/chartreuse/chartreuse.py index b5f6c36..7582986 100644 --- a/src/chartreuse/chartreuse.py +++ b/src/chartreuse/chartreuse.py @@ -2,6 +2,7 @@ import wiremind_kubernetes.kubernetes_helper +from .config_loader import DatabaseConfig from .utils import AlembicMigrationHelper logger = logging.getLogger(__name__) @@ -16,49 +17,11 @@ def configure_logging() -> None: class Chartreuse: - def __init__( - self, - alembic_directory_path: str, - alembic_config_file_path: str, - postgresql_url: str, - release_name: str, - alembic_allow_migration_for_empty_database: bool, - alembic_additional_parameters: str = "", - kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None, - ): - configure_logging() - - self.alembic_migration_helper = AlembicMigrationHelper( - alembic_directory_path=alembic_directory_path, - alembic_config_file_path=alembic_config_file_path, - database_url=postgresql_url, - allow_migration_for_empty_database=alembic_allow_migration_for_empty_database, - additional_parameters=alembic_additional_parameters, - ) - - if kubernetes_helper: - self.kubernetes_helper = kubernetes_helper - else: - self.kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager( - use_kubeconfig=None, release_name=release_name - ) - - self.is_migration_needed = self.check_migration_needed() - - def check_migration_needed(self) -> bool: - return self.alembic_migration_helper.is_migration_needed - - def upgrade(self) -> None: - if self.check_migration_needed(): - self.alembic_migration_helper.upgrade_db() - - -class MultiChartreuse: - """Handles multiple database migrations.""" + """Handles single or multiple database migrations.""" def __init__( self, - databases_config: list[dict], + databases_config: dict[str, DatabaseConfig], release_name: str, kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None, ): @@ -68,24 +31,22 @@ def __init__( self.migration_helpers: dict[str, AlembicMigrationHelper] = {} # Initialize migration helpers for each database - for db_config in databases_config: - db_name = db_config["name"] - logger.info(f"Initializing migration helper for database: {db_name}") + for db_name, db_config in databases_config.items(): + logger.info("Initializing migration helper for database: %s", db_name) # Build additional parameters with section name - additional_params = db_config.get("additional_parameters", "") + additional_params = db_config.additional_parameters # Use database name as section name for alembic -n parameter section_param = f"-n {db_name}" - if section_param: - additional_params = f"{additional_params} {section_param}".strip() + additional_params = f"{additional_params} {section_param}".strip() helper = AlembicMigrationHelper( - alembic_directory_path=db_config["alembic_directory_path"], - alembic_config_file_path=db_config["alembic_config_file_path"], - database_url=db_config["url"], - allow_migration_for_empty_database=db_config.get("allow_migration_for_empty_database", False), + alembic_directory_path=db_config.alembic_directory_path, + alembic_config_file_path=db_config.alembic_config_file_path, + database_url=db_config.url, + allow_migration_for_empty_database=db_config.allow_migration_for_empty_database, additional_parameters=additional_params, alembic_section_name=db_name, ) @@ -99,14 +60,12 @@ def __init__( use_kubeconfig=None, release_name=release_name ) - # Check if any migrations are needed - self.is_migration_needed = self.check_migration_needed() - - def check_migration_needed(self) -> bool: + @property + def is_migration_needed(self) -> bool: """Check if any database needs migration.""" for db_name, helper in self.migration_helpers.items(): if helper.is_migration_needed: - logger.info(f"Database '{db_name}' needs migration") + logger.info("Database '%s' needs migration", db_name) return True return False @@ -114,8 +73,8 @@ def upgrade(self) -> None: """Upgrade all databases that need migration.""" for db_name, helper in self.migration_helpers.items(): if helper.is_migration_needed: - logger.info(f"Upgrading database: {db_name}") + logger.info("Upgrading database: %s", db_name) helper.upgrade_db() - logger.info(f"Successfully upgraded database: {db_name}") + logger.info("Successfully upgraded database: %s", db_name) else: - logger.info(f"Database '{db_name}' is up to date") + logger.info("Database '%s' is up to date", db_name) diff --git a/src/chartreuse/chartreuse_upgrade.py b/src/chartreuse/chartreuse_upgrade.py index fb22eb2..630a6ec 100755 --- a/src/chartreuse/chartreuse_upgrade.py +++ b/src/chartreuse/chartreuse_upgrade.py @@ -5,7 +5,7 @@ from chartreuse import get_version -from .chartreuse import Chartreuse, MultiChartreuse +from .chartreuse import Chartreuse from .config_loader import load_multi_database_config logger = logging.getLogger(__name__) @@ -35,99 +35,80 @@ def ensure_safe_run() -> None: ) +def validate_config_file_path() -> str: + """ + Validate that the multi-database configuration file path is properly configured and accessible. + + Returns: + str: The validated config file path + + Raises: + ValueError: If environment variable is not set or path is not a file + FileNotFoundError: If the config file doesn't exist + """ + multi_config_path = os.environ.get("CHARTREUSE_MULTI_CONFIG_PATH") + + if not multi_config_path: + raise ValueError("CHARTREUSE_MULTI_CONFIG_PATH environment variable is required but not set") + + if not os.path.exists(multi_config_path): + raise FileNotFoundError(f"Multi-database configuration file not found: {multi_config_path}") + + if not os.path.isfile(multi_config_path): + raise ValueError(f"Multi-database configuration path is not a file: {multi_config_path}") + + return multi_config_path + + def main() -> None: """ When put in a post-install Helm hook, if this program fails the whole release is considered as failed. """ ensure_safe_run() - # Check if multi-database configuration is provided - multi_config_path = os.environ.get("CHARTREUSE_MULTI_CONFIG_PATH") - - if multi_config_path: - # Use multi-database configuration - logger.info(f"Using multi-database configuration from: {multi_config_path}") + # Validate and get multi-database configuration path + multi_config_path = validate_config_file_path() + + # Use multi-database configuration + logger.info("Using multi-database configuration from: %s", multi_config_path) + + try: + databases_config = load_multi_database_config(multi_config_path) + except (FileNotFoundError, ValueError) as e: + logger.error("Failed to load multi-database configuration: %s", e) + raise + + ENABLE_STOP_PODS: bool = os.environ.get("CHARTREUSE_ENABLE_STOP_PODS", "true").lower() not in ("", "false", "0") + RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] + UPGRADE_BEFORE_DEPLOYMENT: bool = os.environ.get("CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT", "false").lower() not in ( + "", + "false", + "0", + ) + HELM_IS_INSTALL: bool = os.environ.get("HELM_IS_INSTALL", "false").lower() not in ("", "false", "0") + + deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) + chartreuse = Chartreuse( + databases_config=databases_config, + release_name=RELEASE_NAME, + kubernetes_helper=deployment_manager, + ) + + if chartreuse.is_migration_needed: + if ENABLE_STOP_PODS: + deployment_manager.stop_pods() + + chartreuse.upgrade() + + if not ENABLE_STOP_PODS: + return + if UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL: + return try: - databases_config = load_multi_database_config(multi_config_path) - except (FileNotFoundError, ValueError) as e: - logger.error(f"Failed to load multi-database configuration: {e}") - raise - - ENABLE_STOP_PODS: bool = bool(os.environ.get("CHARTREUSE_ENABLE_STOP_PODS", "true").lower() == "true") - RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] - UPGRADE_BEFORE_DEPLOYMENT: bool = bool( - os.environ.get("CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT", "false").lower() == "true" - ) - HELM_IS_INSTALL: bool = bool(os.environ.get("HELM_IS_INSTALL", "false").lower() == "true") - - deployment_manager = KubernetesDeploymentManager(release_name=RELEASE_NAME, use_kubeconfig=None) - chartreuse = MultiChartreuse( - databases_config=databases_config, - release_name=RELEASE_NAME, - kubernetes_helper=deployment_manager, - ) - - if chartreuse.is_migration_needed: - if ENABLE_STOP_PODS: - deployment_manager.stop_pods() - - chartreuse.upgrade() - - if not ENABLE_STOP_PODS: - return - if UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL: - return - - try: - deployment_manager.start_pods() - except: # noqa: E722 - logger.error( - "Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! " - ) - else: - # Use legacy single-database configuration - logger.info("Using single-database configuration from environment variables") - - ALEMBIC_DIRECTORY_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_DIRECTORY_PATH", "/app/alembic") - ALEMBIC_CONFIG_FILE_PATH: str = os.environ.get("CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH", "alembic.ini") - POSTGRESQL_URL: str | None = os.environ["CHARTREUSE_ALEMBIC_URL"] - ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE: bool = bool( - os.environ["CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE"] - ) - ALEMBIC_ADDITIONAL_PARAMETERS: str = os.environ["CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS"] - SINGLE_ENABLE_STOP_PODS: bool = bool(os.environ["CHARTREUSE_ENABLE_STOP_PODS"]) - SINGLE_RELEASE_NAME: str = os.environ["CHARTREUSE_RELEASE_NAME"] - SINGLE_UPGRADE_BEFORE_DEPLOYMENT: bool = bool(os.environ["CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT"]) - SINGLE_HELM_IS_INSTALL: bool = bool(os.environ["HELM_IS_INSTALL"]) - - deployment_manager = KubernetesDeploymentManager(release_name=SINGLE_RELEASE_NAME, use_kubeconfig=None) - single_chartreuse = Chartreuse( - alembic_directory_path=ALEMBIC_DIRECTORY_PATH, - alembic_config_file_path=ALEMBIC_CONFIG_FILE_PATH, - postgresql_url=POSTGRESQL_URL, - alembic_allow_migration_for_empty_database=ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE, - alembic_additional_parameters=ALEMBIC_ADDITIONAL_PARAMETERS, - release_name=SINGLE_RELEASE_NAME, - kubernetes_helper=deployment_manager, - ) - if single_chartreuse.is_migration_needed: - if SINGLE_ENABLE_STOP_PODS: - deployment_manager.stop_pods() - - single_chartreuse.upgrade() - - if not SINGLE_ENABLE_STOP_PODS: - return - if SINGLE_UPGRADE_BEFORE_DEPLOYMENT and not SINGLE_HELM_IS_INSTALL: - return - - try: - deployment_manager.start_pods() - except: # noqa: E722 - logger.error( - "Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! " - ) + deployment_manager.start_pods() + except Exception: + logger.error("Couldn't scale up new pods in chartreuse_upgrade after migration, SHOULD BE DONE MANUALLY ! ") if __name__ == "__main__": diff --git a/src/chartreuse/config_loader.py b/src/chartreuse/config_loader.py index c64768d..85fc50d 100644 --- a/src/chartreuse/config_loader.py +++ b/src/chartreuse/config_loader.py @@ -1,81 +1,132 @@ """ -Simple configuration loader for multi-database support. +Configuration loader for multi-database support using Pydantic models. """ import logging import yaml +from pydantic import BaseModel, Field, computed_field, field_validator logger = logging.getLogger(__name__) -def build_database_url(db_config: dict) -> str: - """ - Build database URL from individual components. +class DatabaseConfig(BaseModel): + """Configuration for a single database within a multi-database setup.""" - Builds URL from: dialect, user, password, host, port, database - """ - # Build URL from components - required_components = ["dialect", "user", "password", "host", "port", "database"] - missing_components = [comp for comp in required_components if comp not in db_config or not db_config[comp]] + # Database connection components + dialect: str = Field(..., description="Database dialect (e.g., postgresql)") + user: str = Field(..., description="Database user") + password: str = Field(..., description="Database password") + host: str = Field(..., description="Database host") + port: int = Field(..., description="Database port", gt=0, le=65535) + database: str = Field(..., description="Database name") + + # Alembic configuration + alembic_directory_path: str = Field(..., description="Path to Alembic directory") + alembic_config_file_path: str = Field(default="alembic.ini", description="Path to Alembic config file") + + # Optional migration settings + allow_migration_for_empty_database: bool = Field(default=True, description="Allow migrations on empty database") + additional_parameters: str = Field(default="", description="Additional Alembic parameters") + + @computed_field + @property + def url(self) -> str: + """Build database URL from components.""" + return f"{self.dialect}://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" + + @field_validator("dialect") + @classmethod + def validate_dialect(cls, v: str) -> str: + """Validate database dialect.""" + supported_dialects = ["postgresql", "mysql", "sqlite", "oracle", "mssql", "clickhouse"] + if v.lower() not in supported_dialects: + logger.warning("Dialect '%s' might not be supported. Supported dialects: %s", v, supported_dialects) + return v - if missing_components: - raise ValueError(f"Missing required components: {missing_components}") + @field_validator("additional_parameters") + @classmethod + def validate_additional_parameters(cls, v: str) -> str: + """Ensure additional_parameters is a string.""" + return v or "" - dialect = db_config["dialect"] - user = db_config["user"] - password = db_config["password"] - host = db_config["host"] - port = db_config["port"] - database = db_config["database"] - return f"{dialect}://{user}:{password}@{host}:{port}/{database}" +class MultiDatabaseConfig(BaseModel): + """Multi-database configuration - the only supported configuration format.""" + databases: dict[str, DatabaseConfig] = Field(..., description="Dictionary of database configurations") -def load_multi_database_config(config_path: str) -> list[dict]: + @field_validator("databases") + @classmethod + def validate_databases_not_empty(cls, v: dict[str, DatabaseConfig]) -> dict[str, DatabaseConfig]: + """Ensure at least one database is configured.""" + if not v: + raise ValueError("At least one database must be configured") + return v + + +def load_multi_database_config(config_path: str) -> dict[str, DatabaseConfig]: """ - Load multi-database configuration from YAML file. + Load multi-database configuration from YAML file using Pydantic validation. + + This is the only supported configuration format. Single database setups should + be configured as a multi-database config with one entry. + + Args: + config_path: Path to YAML configuration file + + Returns: + Dictionary of database configurations keyed by database name - Expected format: + Raises: + FileNotFoundError: If configuration file is not found + ValueError: If configuration is invalid + + Expected YAML format: + ```yaml databases: - main: - alembic_directory_path: /app/alembic/main - alembic_config_file_path: alembic.ini + main: # Database name/identifier dialect: postgresql - user: myuser - password: mypassword - host: localhost + user: app_user + password: app_password + host: postgres-main port: 5432 - database: mydb + database: app_main + alembic_directory_path: /app/alembic/main + alembic_config_file_path: alembic.ini allow_migration_for_empty_database: true additional_parameters: "" + + # For single database setups, just include one database: + # analytics: + # dialect: postgresql + # user: analytics_user + # password: analytics_password + # host: postgres-analytics + # port: 5432 + # database: analytics + # alembic_directory_path: /app/alembic/analytics + # alembic_config_file_path: alembic.ini + # allow_migration_for_empty_database: false + # additional_parameters: "" + ``` """ try: with open(config_path) as f: - config = yaml.safe_load(f) - - if "databases" not in config: - raise ValueError("Configuration must contain 'databases' key") - - databases_dict = config["databases"] - - if not isinstance(databases_dict, dict): - raise ValueError("'databases' must be a dictionary with database names as keys") - - # Convert dictionary to list format for internal processing - databases = [] - for db_name, db_config in databases_dict.items(): - # Add the database name to the config - db_config["name"] = db_name + raw_config = yaml.safe_load(f) - # Build URL from components - db_config["url"] = build_database_url(db_config) + # Validate the configuration using Pydantic + config = MultiDatabaseConfig(**raw_config) - databases.append(db_config) + logger.info( + "Loaded configuration for %d database(s): %s", len(config.databases), ", ".join(config.databases.keys()) + ) - return databases + return config.databases except FileNotFoundError as err: raise FileNotFoundError(f"Configuration file not found: {config_path}") from err except yaml.YAMLError as e: raise ValueError(f"Invalid YAML in configuration file: {e}") from e + except Exception as e: + raise ValueError(f"Invalid configuration: {e}") from e diff --git a/src/chartreuse/tests/conftest.py b/src/chartreuse/tests/conftest.py index 9cba59e..1232161 100644 --- a/src/chartreuse/tests/conftest.py +++ b/src/chartreuse/tests/conftest.py @@ -14,8 +14,54 @@ def configure_os_environ_mock(mocker: MockerFixture, additional_environment: dic "RUN_TEST_IN_KIND": os.environ.get("RUN_TEST_IN_KIND", ""), "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "", "HELM_IS_INSTALL": "", + "CHARTREUSE_MULTI_CONFIG_PATH": "/mock/config.yaml", + # Add Kubernetes environment variables to prevent config loading + "KUBERNETES_SERVICE_HOST": "localhost", + "KUBERNETES_SERVICE_PORT": "443", } if additional_environment: new_environ.update(additional_environment) mocker.patch.dict(os.environ, new_environ) + + # Mock the file operations for the config path + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + + # Mock the open operation for kubernetes token file + mock_open = mocker.mock_open(read_data="mock-token") + mocker.patch("builtins.open", mock_open) + + # Mock the config loader + mock_config = [ + { + "name": "default", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + "url": "foo", + "allow_migration_for_empty_database": True, + "additional_parameters": "", + } + ] + mocker.patch("chartreuse.chartreuse_upgrade.load_multi_database_config", return_value=mock_config) + + +def configure_chartreuse_mock( + mocker: MockerFixture, + is_migration_needed: bool, +) -> None: + """ + Configure a mock for Chartreuse object. + """ + mock_chartreuse = mocker.MagicMock() + mock_chartreuse.is_migration_needed = is_migration_needed + mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse", return_value=mock_chartreuse) + + # Mock KubernetesDeploymentManager in all the right places + mock_kdm = mocker.MagicMock() + mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager", return_value=mock_kdm) + mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager", return_value=mock_kdm) + + # Also mock the kubernetes config loading to prevent the config errors + mocker.patch("kubernetes.config.load_incluster_config") + mocker.patch("wiremind_kubernetes.kube_config.load_kubernetes_config") diff --git a/src/chartreuse/tests/e2e_tests/conftest.py b/src/chartreuse/tests/e2e_tests/conftest.py index 6bdccce..bed5fe4 100644 --- a/src/chartreuse/tests/e2e_tests/conftest.py +++ b/src/chartreuse/tests/e2e_tests/conftest.py @@ -42,11 +42,13 @@ def _cluster_init(include_chartreuse: bool, pre_upgrade: bool = False) -> Genera else: additional_args = "--set chartreuse.enabled=true --set chartreuse.upgradeBeforeDeployment=false" run_command( - f"helm install --wait {TEST_RELEASE} {HELM_CHART_PATH} --namespace {TEST_NAMESPACE} --timeout 180s {additional_args}", # noqa: E501 + f"""helm install --wait {TEST_RELEASE} {HELM_CHART_PATH} + --namespace {TEST_NAMESPACE} --timeout 180s {additional_args}""", cwd=EXAMPLE_PATH, ) run_command( - f"helm upgrade --wait {TEST_RELEASE} {HELM_CHART_PATH} --namespace {TEST_NAMESPACE} --timeout 60s {additional_args}", # noqa: E501 + f"""helm upgrade --wait {TEST_RELEASE} {HELM_CHART_PATH} + --namespace {TEST_NAMESPACE} --timeout 60s {additional_args}""", cwd=EXAMPLE_PATH, ) @@ -63,7 +65,8 @@ def _cluster_init(include_chartreuse: bool, pre_upgrade: bool = False) -> Genera time.sleep(5) # Hack to wait for k exec to be up except: # noqa run_command( - f"kubectl logs --selector app.kubernetes.io/instance=e2e-test-release --all-containers=false --namespace {TEST_NAMESPACE} --tail 1000" # noqa: E501 + f"""kubectl logs --selector app.kubernetes.io/instance=e2e-test-release + --all-containers=false --namespace {TEST_NAMESPACE} --tail 1000""" ) run_command(f"kubectl delete namespace {TEST_NAMESPACE} --grace-period=1") raise diff --git a/src/chartreuse/tests/e2e_tests/test_blackbox.py b/src/chartreuse/tests/e2e_tests/test_blackbox.py deleted file mode 100644 index cc48ffd..0000000 --- a/src/chartreuse/tests/e2e_tests/test_blackbox.py +++ /dev/null @@ -1,31 +0,0 @@ -# import logging - -# from pytest_mock.plugin import MockerFixture - -# from .conftest import are_pods_scaled_down, assert_sql_upgraded - -# logger = logging.getLogger(__name__) - - -# # def test_chartreuse_blackbox_post_upgrade( -# # prepare_container_image_and_helm_chart: MockerFixture, -# # populate_cluster_with_chartreuse_post_upgrade: MockerFixture, -# # mocker: MockerFixture, -# # ) -> None: -# # """ -# # Test chartreuse considered as a blackbox, in post-install,post-upgrade configuration -# # """ -# # assert_sql_upgraded() -# # assert not are_pods_scaled_down() - - -# # def test_chartreuse_blackbox_pre_upgrade( -# # prepare_container_image_and_helm_chart: MockerFixture, -# # populate_cluster_with_chartreuse_pre_upgrade: MockerFixture, -# # mocker: MockerFixture, -# # ) -> None: -# # """ -# # Test chartreuse considered as a blackbox, in pre-upgrade configuration -# # """ -# # assert_sql_upgraded() -# # assert not are_pods_scaled_down() diff --git a/src/chartreuse/tests/e2e_tests/test_integration.py b/src/chartreuse/tests/e2e_tests/test_integration.py deleted file mode 100644 index 757fe81..0000000 --- a/src/chartreuse/tests/e2e_tests/test_integration.py +++ /dev/null @@ -1,32 +0,0 @@ -# import chartreuse.chartreuse_migrate - -# from .conftest import ( -# TEST_NAMESPACE, -# TEST_RELEASE, -# POSTGRESQL_URL, -# SAMPLE_ALEMBIC_PATH, -# assert_sql_upgraded, -# are_pods_scaled_down, -# ) -# from ..conftest import configure_os_environ_mock - - -# def test_integration_chartreuse_upgrade(populate_cluster, mocker): -# """ -# Test chartreuse upgrade, in post-install,post-upgrade configuration -# """ -# mocker.patch("chartreuse.utils.alembic_migration_helper.ALEMBIC_DIRECTORY_PATH", SAMPLE_ALEMBIC_PATH) -# mocker.patch("wiremind_kubernetes.kubernetes_helper._get_namespace_from_kube", return_value=TEST_NAMESPACE) -# configure_os_environ_mock( -# mocker=mocker, -# additional_environment=dict( -# CHARTREUSE_ALEMBIC_URL=POSTGRESQL_URL, -# CHARTREUSE_MIGRATE_CONTAINER_IMAGE=f"docker.wiremind.fr/wiremind/devops/chartreuse:latest", -# CHARTREUSE_RELEASE_NAME=TEST_RELEASE, -# ), -# ) - -# chartreuse.chartreuse_upgrade.main() - -# assert_sql_upgraded() -# assert not are_pods_scaled_down() diff --git a/src/chartreuse/tests/unit_tests/test_alembic.py b/src/chartreuse/tests/unit_tests/test_alembic.py index 21dcbe5..f478a44 100644 --- a/src/chartreuse/tests/unit_tests/test_alembic.py +++ b/src/chartreuse/tests/unit_tests/test_alembic.py @@ -15,7 +15,9 @@ def test_respect_empty_database(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_table_list", return_value=table_list, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", alembic_section_name="test", configure=False + ) assert alembic_migration_helper.is_postgres_empty() @@ -32,7 +34,9 @@ def test_respect_not_empty_database(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value="123", ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", alembic_section_name="test", configure=False + ) assert alembic_migration_helper.is_postgres_empty() is False @@ -56,7 +60,9 @@ def test_detect_needed_migration(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", alembic_section_name="test", configure=False + ) assert alembic_migration_helper.is_migration_needed @@ -81,7 +87,9 @@ def test_detect_not_needed_migration(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", alembic_section_name="test", configure=False + ) assert alembic_migration_helper.is_migration_needed is False @@ -101,7 +109,9 @@ def test_detect_needed_migration_non_existent(mocker: MockerFixture) -> None: "chartreuse.utils.AlembicMigrationHelper._get_alembic_current", return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", alembic_section_name="test", configure=False + ) assert alembic_migration_helper.is_migration_needed @@ -122,7 +132,9 @@ def test_detect_database_is_empty(mocker: MockerFixture) -> None: return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", alembic_section_name="test", configure=False + ) assert alembic_migration_helper.is_postgres_empty() @@ -143,7 +155,9 @@ def test_detect_database_is_not_empty(mocker: MockerFixture) -> None: return_value=sample_alembic_output, ) - alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper(database_url="foo", configure=False) + alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( + database_url="foo", alembic_section_name="test", configure=False + ) assert alembic_migration_helper.is_postgres_empty() is False @@ -161,7 +175,7 @@ def test_additional_parameters(mocker: MockerFixture) -> None: mocked_run_command = mocker.patch("chartreuse.utils.alembic_migration_helper.run_command") alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False, additional_parameters="foo bar" + database_url="foo", alembic_section_name="test", configure=False, additional_parameters="foo bar" ) alembic_migration_helper.upgrade_db() mocked_run_command.assert_called_with("alembic -c alembic.ini foo bar upgrade head", cwd="/app/alembic") @@ -176,7 +190,7 @@ def test_additional_parameters_current(mocker: MockerFixture) -> None: mocked_run_command.return_value = ("bar", None, 0) alembic_migration_helper = chartreuse.utils.AlembicMigrationHelper( - database_url="foo", configure=False, additional_parameters="foo bar" + database_url="foo", alembic_section_name="test", configure=False, additional_parameters="foo bar" ) alembic_migration_helper._get_alembic_current() mocked_run_command.assert_called_with( @@ -349,9 +363,9 @@ def test_multi_database_configuration_both_sections(mocker: MockerFixture) -> No assert "[loggers]" in final_content -def test_single_database_configuration_still_works(mocker: MockerFixture) -> None: +def test_single_database_configuration_with_alembic_section(mocker: MockerFixture) -> None: """ - Test that single-database configuration (legacy behavior) still works when alembic_section_name is None + Test that single-database configuration works when alembic_section_name is specified """ # Sample simple alembic.ini content (single database) sample_alembic_ini = """[alembic] @@ -367,12 +381,12 @@ def test_single_database_configuration_still_works(mocker: MockerFixture) -> Non with open(alembic_ini_path, "w") as f: f.write(sample_alembic_ini) - # Test single database configuration (alembic_section_name=None) + # Test single database configuration with explicit section name chartreuse.utils.AlembicMigrationHelper( alembic_directory_path=temp_dir, alembic_config_file_path="alembic.ini", database_url="postgresql://new_user:new_pass@new_host:5432/new_db", - alembic_section_name=None, # Single database mode + alembic_section_name="alembic", configure=True, skip_db_checks=True, ) diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse.py b/src/chartreuse/tests/unit_tests/test_chartreuse.py index b50a6a6..d30e074 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse.py @@ -5,7 +5,8 @@ from pytest_mock.plugin import MockerFixture -from chartreuse.chartreuse import Chartreuse, MultiChartreuse, configure_logging +from chartreuse.chartreuse import Chartreuse, configure_logging +from chartreuse.config_loader import DatabaseConfig class TestConfigureLogging: @@ -41,13 +42,25 @@ def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> # Create a mock kubernetes helper mock_k8s_helper = MagicMock() + # Use the new dictionary format with DatabaseConfig objects + databases_config = { + "test-db": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + allow_migration_for_empty_database=True, + additional_parameters="--verbose", + ) + } + chartreuse = Chartreuse( - alembic_directory_path="/app/alembic", - alembic_config_file_path="alembic.ini", - postgresql_url="postgresql://user:pass@localhost:5432/db", + databases_config=databases_config, release_name="test-release", - alembic_allow_migration_for_empty_database=True, - alembic_additional_parameters="--verbose", kubernetes_helper=mock_k8s_helper, ) @@ -57,7 +70,8 @@ def test_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> alembic_config_file_path="alembic.ini", database_url="postgresql://user:pass@localhost:5432/db", allow_migration_for_empty_database=True, - additional_parameters="--verbose", + additional_parameters="--verbose -n test-db", + alembic_section_name="test-db", ) # Verify kubernetes helper is set @@ -80,12 +94,25 @@ def test_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) # Mock configure_logging mocker.patch("chartreuse.chartreuse.configure_logging") + # Use the new dictionary format with DatabaseConfig objects + databases_config = { + "test-db": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + allow_migration_for_empty_database=False, + additional_parameters="", + ) + } + chartreuse = Chartreuse( - alembic_directory_path="/app/alembic", - alembic_config_file_path="alembic.ini", - postgresql_url="postgresql://user:pass@localhost:5432/db", + databases_config=databases_config, release_name="test-release", - alembic_allow_migration_for_empty_database=False, ) # Verify KubernetesDeploymentManager was initialized @@ -95,7 +122,7 @@ def test_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFixture) assert chartreuse.is_migration_needed is False def test_check_migration_needed(self, mocker: MockerFixture) -> None: - """Test check_migration_needed method.""" + """Test is_migration_needed property.""" mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") mock_alembic_instance = MagicMock() mock_alembic_instance.is_migration_needed = True @@ -104,21 +131,34 @@ def test_check_migration_needed(self, mocker: MockerFixture) -> None: mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() + # Use the new dictionary format with DatabaseConfig objects + databases_config = { + "test-db": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + allow_migration_for_empty_database=True, + additional_parameters="", + ) + } + chartreuse = Chartreuse( - alembic_directory_path="/app/alembic", - alembic_config_file_path="alembic.ini", - postgresql_url="postgresql://user:pass@localhost:5432/db", + databases_config=databases_config, release_name="test-release", - alembic_allow_migration_for_empty_database=True, kubernetes_helper=mock_k8s_helper, ) - result = chartreuse.check_migration_needed() + result = chartreuse.is_migration_needed assert result is True # Change the mock return value mock_alembic_instance.is_migration_needed = False - result = chartreuse.check_migration_needed() + result = chartreuse.is_migration_needed assert result is False def test_upgrade_when_migration_needed(self, mocker: MockerFixture) -> None: @@ -131,12 +171,25 @@ def test_upgrade_when_migration_needed(self, mocker: MockerFixture) -> None: mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() + # Use the new dictionary format with DatabaseConfig objects + databases_config = { + "test-db": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + allow_migration_for_empty_database=True, + additional_parameters="", + ) + } + chartreuse = Chartreuse( - alembic_directory_path="/app/alembic", - alembic_config_file_path="alembic.ini", - postgresql_url="postgresql://user:pass@localhost:5432/db", + databases_config=databases_config, release_name="test-release", - alembic_allow_migration_for_empty_database=True, kubernetes_helper=mock_k8s_helper, ) @@ -155,12 +208,25 @@ def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() + # Use the new dictionary format with DatabaseConfig objects + databases_config = { + "test-db": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + allow_migration_for_empty_database=True, + additional_parameters="", + ) + } + chartreuse = Chartreuse( - alembic_directory_path="/app/alembic", - alembic_config_file_path="alembic.ini", - postgresql_url="postgresql://user:pass@localhost:5432/db", + databases_config=databases_config, release_name="test-release", - alembic_allow_migration_for_empty_database=True, kubernetes_helper=mock_k8s_helper, ) @@ -170,11 +236,11 @@ def test_upgrade_when_migration_not_needed(self, mocker: MockerFixture) -> None: mock_alembic_instance.upgrade_db.assert_not_called() -class TestMultiChartreuse: - """Test cases for MultiChartreuse class.""" +class TestChartreuseMultiDatabase: + """Test cases for Chartreuse class with multiple databases.""" def test_multi_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixture) -> None: - """Test MultiChartreuse initialization with provided kubernetes helper.""" + """Test Chartreuse initialization with provided kubernetes helper.""" # Mock AlembicMigrationHelper mock_alembic_helper = mocker.patch("chartreuse.chartreuse.AlembicMigrationHelper") @@ -194,24 +260,32 @@ def test_multi_chartreuse_init_with_kubernetes_helper(self, mocker: MockerFixtur # Create a mock kubernetes helper mock_k8s_helper = MagicMock() - databases_config: list[dict[str, any]] = [ - { - "name": "main", - "alembic_directory_path": "/app/alembic/main", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5432/maindb", - "allow_migration_for_empty_database": True, - "additional_parameters": "--verbose", - }, - { - "name": "secondary", - "alembic_directory_path": "/app/alembic/secondary", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5433/secdb", - }, - ] - - multi_chartreuse = MultiChartreuse( + databases_config = { + "main": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="maindb", + alembic_directory_path="/app/alembic/main", + alembic_config_file_path="alembic.ini", + allow_migration_for_empty_database=True, + additional_parameters="--verbose", + ), + "secondary": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5433, + database="secdb", + alembic_directory_path="/app/alembic/secondary", + alembic_config_file_path="alembic.ini", + ), + } + + multi_chartreuse = Chartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, @@ -248,16 +322,20 @@ def test_multi_chartreuse_init_without_kubernetes_helper(self, mocker: MockerFix # Mock configure_logging mocker.patch("chartreuse.chartreuse.configure_logging") - databases_config: list[dict[str, any]] = [ - { - "name": "test", - "alembic_directory_path": "/app/alembic", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5432/db", - } - ] - - multi_chartreuse = MultiChartreuse( + databases_config = { + "test": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + ) + } + + multi_chartreuse = Chartreuse( databases_config=databases_config, release_name="test-release", ) @@ -283,29 +361,37 @@ def test_check_migration_needed_with_mixed_needs(self, mocker: MockerFixture) -> mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - databases_config: list[dict[str, any]] = [ - { - "name": "main", - "alembic_directory_path": "/app/alembic/main", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5432/maindb", - }, - { - "name": "secondary", - "alembic_directory_path": "/app/alembic/secondary", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5433/secdb", - }, - ] - - multi_chartreuse = MultiChartreuse( + databases_config = { + "main": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="maindb", + alembic_directory_path="/app/alembic/main", + alembic_config_file_path="alembic.ini", + ), + "secondary": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5433, + database="secdb", + alembic_directory_path="/app/alembic/secondary", + alembic_config_file_path="alembic.ini", + ), + } + + multi_chartreuse = Chartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, ) # Should return True because secondary needs migration - result = multi_chartreuse.check_migration_needed() + result = multi_chartreuse.is_migration_needed assert result is True def test_check_migration_needed_none_need_migration(self, mocker: MockerFixture) -> None: @@ -319,22 +405,26 @@ def test_check_migration_needed_none_need_migration(self, mocker: MockerFixture) mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - databases_config: list[dict[str, any]] = [ - { - "name": "test", - "alembic_directory_path": "/app/alembic", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5432/db", - } - ] - - multi_chartreuse = MultiChartreuse( + databases_config = { + "test": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + ) + } + + multi_chartreuse = Chartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, ) - result = multi_chartreuse.check_migration_needed() + result = multi_chartreuse.is_migration_needed assert result is False def test_upgrade_only_needed_databases(self, mocker: MockerFixture) -> None: @@ -353,22 +443,30 @@ def test_upgrade_only_needed_databases(self, mocker: MockerFixture) -> None: mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - databases_config: list[dict[str, any]] = [ - { - "name": "main", - "alembic_directory_path": "/app/alembic/main", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5432/maindb", - }, - { - "name": "secondary", - "alembic_directory_path": "/app/alembic/secondary", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5433/secdb", - }, - ] - - multi_chartreuse = MultiChartreuse( + databases_config = { + "main": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="maindb", + alembic_directory_path="/app/alembic/main", + alembic_config_file_path="alembic.ini", + ), + "secondary": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5433, + database="secdb", + alembic_directory_path="/app/alembic/secondary", + alembic_config_file_path="alembic.ini", + ), + } + + multi_chartreuse = Chartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, @@ -390,17 +488,21 @@ def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: mocker.patch("chartreuse.chartreuse.configure_logging") mock_k8s_helper = MagicMock() - databases_config = [ - { - "name": "test", - "alembic_directory_path": "/app/alembic", - "alembic_config_file_path": "alembic.ini", - "url": "postgresql://user:pass@localhost:5432/db", - # No allow_migration_for_empty_database or additional_parameters - } - ] - - MultiChartreuse( + databases_config = { + "test": DatabaseConfig( + dialect="postgresql", + user="user", + password="pass", + host="localhost", + port=5432, + database="db", + alembic_directory_path="/app/alembic", + alembic_config_file_path="alembic.ini", + # No allow_migration_for_empty_database or additional_parameters (using defaults) + ) + } + + Chartreuse( databases_config=databases_config, release_name="test-release", kubernetes_helper=mock_k8s_helper, @@ -411,7 +513,7 @@ def test_multi_chartreuse_default_values(self, mocker: MockerFixture) -> None: alembic_directory_path="/app/alembic", alembic_config_file_path="alembic.ini", database_url="postgresql://user:pass@localhost:5432/db", - allow_migration_for_empty_database=False, # Default value - additional_parameters="-n test", # Section name parameter added + allow_migration_for_empty_database=True, # Default value from DatabaseConfig + additional_parameters="-n test", # Section name parameter added (no leading space when original is empty) alembic_section_name="test", # New parameter for multi-database support ) diff --git a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py index 076ef2c..2c82f5e 100644 --- a/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py +++ b/src/chartreuse/tests/unit_tests/test_chartreuse_upgrade_extended.py @@ -78,6 +78,11 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: # Mock ensure_safe_run mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + # Mock config loading mock_config = [ { @@ -92,8 +97,8 @@ def test_main_multi_database_success(self, mocker: MockerFixture) -> None: return_value=mock_config, ) - # Mock MultiChartreuse - mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") + # Mock Chartreuse + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance @@ -128,6 +133,10 @@ def test_main_multi_database_no_migration_needed(self, mocker: MockerFixture) -> """Test main function when no migration is needed.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + mock_config = [ { "name": "main", @@ -141,7 +150,7 @@ def test_main_multi_database_no_migration_needed(self, mocker: MockerFixture) -> return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = False mock_multi_chartreuse.return_value = mock_chartreuse_instance @@ -192,6 +201,10 @@ def test_main_multi_database_stop_pods_disabled(self, mocker: MockerFixture) -> """Test main function with ENABLE_STOP_PODS disabled.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + mock_config = [ { "name": "main", @@ -205,7 +218,7 @@ def test_main_multi_database_stop_pods_disabled(self, mocker: MockerFixture) -> return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance @@ -236,6 +249,10 @@ def test_main_multi_database_upgrade_before_deployment(self, mocker: MockerFixtu """Test main function with UPGRADE_BEFORE_DEPLOYMENT and not HELM_IS_INSTALL.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + mock_config = [ { "name": "main", @@ -249,7 +266,7 @@ def test_main_multi_database_upgrade_before_deployment(self, mocker: MockerFixtu return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance @@ -280,6 +297,10 @@ def test_main_multi_database_start_pods_failure(self, mocker: MockerFixture) -> """Test main function when start_pods fails.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + mock_config = [ { "name": "main", @@ -293,7 +314,7 @@ def test_main_multi_database_start_pods_failure(self, mocker: MockerFixture) -> return_value=mock_config, ) - mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.MultiChartreuse") + mock_multi_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True mock_multi_chartreuse.return_value = mock_chartreuse_instance @@ -332,6 +353,26 @@ def test_main_single_database_success(self, mocker: MockerFixture) -> None: """Test main function with successful single-database configuration.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + + # Mock config loading for single database + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/db", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + "alembic_allow_migration_for_empty_database": True, + "alembic_additional_parameters": "--verbose", + } + ] + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = True @@ -341,15 +382,11 @@ def test_main_single_database_success(self, mocker: MockerFixture) -> None: mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - # Set up environment for single-database mode (no CHARTREUSE_MULTI_CONFIG_PATH) + # Set up environment for unified mode (always requires CHARTREUSE_MULTI_CONFIG_PATH) mocker.patch.dict( os.environ, { - "CHARTREUSE_ALEMBIC_DIRECTORY_PATH": "/app/alembic", - "CHARTREUSE_ALEMBIC_CONFIG_FILE_PATH": "alembic.ini", - "CHARTREUSE_ALEMBIC_URL": "postgresql://user:pass@localhost:5432/db", - "CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE": "true", - "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS": "--verbose", + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", "CHARTREUSE_ENABLE_STOP_PODS": "true", "CHARTREUSE_RELEASE_NAME": "test-release", "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", @@ -360,13 +397,9 @@ def test_main_single_database_success(self, mocker: MockerFixture) -> None: main() - # Verify Chartreuse was initialized correctly + # Verify Chartreuse was initialized correctly with unified architecture mock_chartreuse.assert_called_once_with( - alembic_directory_path="/app/alembic", - alembic_config_file_path="alembic.ini", - postgresql_url="postgresql://user:pass@localhost:5432/db", - alembic_allow_migration_for_empty_database=True, - alembic_additional_parameters="--verbose", + databases_config=mock_config, release_name="test-release", kubernetes_helper=mock_k8s_instance, ) @@ -380,6 +413,26 @@ def test_main_single_database_default_values(self, mocker: MockerFixture) -> Non """Test main function with default values for optional environment variables.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + + # Mock config loading for single database with default values + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/db", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + "alembic_allow_migration_for_empty_database": False, + "alembic_additional_parameters": "", + } + ] + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() mock_chartreuse_instance.is_migration_needed = False @@ -389,13 +442,11 @@ def test_main_single_database_default_values(self, mocker: MockerFixture) -> Non mock_k8s_instance = mocker.MagicMock() mock_k8s_manager.return_value = mock_k8s_instance - # Set minimal required environment variables + # Set minimal required environment variables for unified mode mocker.patch.dict( os.environ, { - "CHARTREUSE_ALEMBIC_URL": "postgresql://user:pass@localhost:5432/db", - "CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE": "false", - "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS": "", + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", "CHARTREUSE_ENABLE_STOP_PODS": "false", "CHARTREUSE_RELEASE_NAME": "test-release", "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", @@ -406,24 +457,13 @@ def test_main_single_database_default_values(self, mocker: MockerFixture) -> Non main() - # Verify Chartreuse was initialized with default values - # Note: The boolean parsing in single-database mode uses bool(env_var) - # which means any non-empty string is True + # Verify Chartreuse was initialized correctly with unified architecture mock_chartreuse.assert_called_once_with( - alembic_directory_path="/app/alembic", # Default value - alembic_config_file_path="alembic.ini", # Default value - postgresql_url="postgresql://user:pass@localhost:5432/db", - alembic_allow_migration_for_empty_database=True, # "false" -> bool("false") -> True - alembic_additional_parameters="", + databases_config=mock_config, release_name="test-release", kubernetes_helper=mock_k8s_instance, ) - # No migration needed, so no pods operations - mock_chartreuse_instance.upgrade.assert_not_called() - mock_k8s_instance.stop_pods.assert_not_called() - mock_k8s_instance.start_pods.assert_not_called() - class TestMainBooleanParsing: """Test cases for boolean environment variable parsing.""" @@ -431,38 +471,56 @@ class TestMainBooleanParsing: @pytest.mark.parametrize( "bool_str,expected", [ - ("true", True), - ("TRUE", True), - ("True", True), - ("false", True), # bool("false") -> True (non-empty string) - ("FALSE", True), # bool("FALSE") -> True (non-empty string) - ("False", True), # bool("False") -> True (non-empty string) - ("", False), # bool("") -> False (empty string) - ("0", True), # bool("0") -> True (non-empty string) - ("1", True), # bool("1") -> True (non-empty string) + ("true", True), # .lower() not in ("", "false", "0") -> "true" not in -> True + ("TRUE", True), # .lower() not in ("", "false", "0") -> "true" not in -> True + ("True", True), # .lower() not in ("", "false", "0") -> "true" not in -> True + ("false", False), # .lower() not in ("", "false", "0") -> "false" in -> False + ("FALSE", False), # .lower() not in ("", "false", "0") -> "false" in -> False + ("False", False), # .lower() not in ("", "false", "0") -> "false" in -> False + ("", False), # .lower() not in ("", "false", "0") -> "" in -> False + ("0", False), # .lower() not in ("", "false", "0") -> "0" in -> False + ("1", True), # .lower() not in ("", "false", "0") -> "1" not in -> True ], ) def test_boolean_parsing_variations(self, mocker: MockerFixture, bool_str: str, expected: bool) -> None: """Test various boolean string parsing in environment variables.""" mocker.patch("chartreuse.chartreuse_upgrade.ensure_safe_run") + # Mock file existence for config validation + mocker.patch("os.path.exists", return_value=True) + mocker.patch("os.path.isfile", return_value=True) + + # Mock config loading + mock_config = [ + { + "name": "main", + "url": "postgresql://user:pass@localhost:5432/db", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "alembic.ini", + } + ] + mocker.patch( + "chartreuse.chartreuse_upgrade.load_multi_database_config", + return_value=mock_config, + ) + mock_chartreuse = mocker.patch("chartreuse.chartreuse_upgrade.Chartreuse") mock_chartreuse_instance = mocker.MagicMock() - mock_chartreuse_instance.is_migration_needed = False + mock_chartreuse_instance.is_migration_needed = True # Set to True to test stop_pods behavior mock_chartreuse.return_value = mock_chartreuse_instance - mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") + mock_k8s_manager = mocker.patch("chartreuse.chartreuse_upgrade.KubernetesDeploymentManager") + mock_k8s_instance = mocker.MagicMock() + mock_k8s_manager.return_value = mock_k8s_instance - # Set up environment for single-database mode (no CHARTREUSE_MULTI_CONFIG_PATH) - # Note: In single-database mode, boolean parsing uses bool(env_var) - # which means any non-empty string is True, empty string is False + # Test boolean parsing for environment variables that are still parsed in main() + # The boolean parsing logic: .lower() not in ("", "false", "0") + # which means: empty string, "false", or "0" are False, everything else is True mocker.patch.dict( os.environ, { - "CHARTREUSE_ALEMBIC_URL": "postgresql://user:pass@localhost:5432/db", - "CHARTREUSE_ALEMBIC_ALLOW_MIGRATION_FOR_EMPTY_DATABASE": bool_str, - "CHARTREUSE_ALEMBIC_ADDITIONAL_PARAMETERS": "", - "CHARTREUSE_ENABLE_STOP_PODS": "false", + "CHARTREUSE_MULTI_CONFIG_PATH": "/app/config.yaml", + "CHARTREUSE_ENABLE_STOP_PODS": bool_str, # Test this boolean parsing "CHARTREUSE_RELEASE_NAME": "test-release", "CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT": "false", "HELM_IS_INSTALL": "false", @@ -472,6 +530,8 @@ def test_boolean_parsing_variations(self, mocker: MockerFixture, bool_str: str, main() - # Check that the boolean was parsed correctly - call_args = mock_chartreuse.call_args[1] - assert call_args["alembic_allow_migration_for_empty_database"] == expected + # Verify that stop_pods was called based on the boolean parsing result + if expected: + mock_k8s_instance.stop_pods.assert_called_once() + else: + mock_k8s_instance.stop_pods.assert_not_called() diff --git a/src/chartreuse/tests/unit_tests/test_config_loader.py b/src/chartreuse/tests/unit_tests/test_config_loader.py index 4af9470..17472c3 100644 --- a/src/chartreuse/tests/unit_tests/test_config_loader.py +++ b/src/chartreuse/tests/unit_tests/test_config_loader.py @@ -1,260 +1,520 @@ -"""Unit tests for config_loader module.""" +""" +Unit tests for config_loader module. +""" -import os import tempfile +from pathlib import Path +from unittest.mock import patch import pytest import yaml +from pydantic import ValidationError -from chartreuse.config_loader import build_database_url, load_multi_database_config +from chartreuse.config_loader import DatabaseConfig, MultiDatabaseConfig, load_multi_database_config -class TestBuildDatabaseUrl: - """Test cases for build_database_url function.""" +class TestDatabaseConfig: + """Test cases for DatabaseConfig model.""" - def test_build_database_url_success(self) -> None: - """Test successful database URL building with all required components.""" - db_config = { + def test_valid_database_config(self): + """Test creating a valid database configuration.""" + config_data = { "dialect": "postgresql", "user": "testuser", "password": "testpass", "host": "localhost", - "port": "5432", + "port": 5432, "database": "testdb", + "alembic_directory_path": "/app/alembic", } - expected_url = "postgresql://testuser:testpass@localhost:5432/testdb" - result = build_database_url(db_config) - - assert result == expected_url - - def test_build_database_url_missing_components(self) -> None: - """Test that missing components raise ValueError.""" - db_config = { - "dialect": "postgresql", + config = DatabaseConfig(**config_data) + + assert config.dialect == "postgresql" + assert config.user == "testuser" + assert config.password == "testpass" + assert config.host == "localhost" + assert config.port == 5432 + assert config.database == "testdb" + assert config.alembic_directory_path == "/app/alembic" + assert config.alembic_config_file_path == "alembic.ini" # default value + assert config.allow_migration_for_empty_database is True # default value + assert config.additional_parameters == "" # default value + + def test_database_config_url_computation(self): + """Test that the URL is computed correctly from components.""" + config = DatabaseConfig( + dialect="postgresql", + user="myuser", + password="mypass", + host="example.com", + port=5432, + database="mydb", + alembic_directory_path="/app/alembic", + ) + + expected_url = "postgresql://myuser:mypass@example.com:5432/mydb" + assert config.url == expected_url + + def test_database_config_with_optional_fields(self): + """Test database configuration with all optional fields set.""" + config_data = { + "dialect": "mysql", "user": "testuser", - # Missing password, host, port, database - } - - with pytest.raises(ValueError) as exc_info: - build_database_url(db_config) - - assert "Missing required components" in str(exc_info.value) - assert "password" in str(exc_info.value) - assert "host" in str(exc_info.value) - assert "port" in str(exc_info.value) - assert "database" in str(exc_info.value) - - def test_build_database_url_empty_components(self) -> None: - """Test that empty string components are treated as missing.""" - db_config = { - "dialect": "postgresql", - "user": "", # Empty string "password": "testpass", "host": "localhost", - "port": "5432", + "port": 3306, "database": "testdb", + "alembic_directory_path": "/app/alembic", + "alembic_config_file_path": "custom_alembic.ini", + "allow_migration_for_empty_database": False, + "additional_parameters": "-x custom_param=value", } - with pytest.raises(ValueError) as exc_info: - build_database_url(db_config) + config = DatabaseConfig(**config_data) - assert "Missing required components" in str(exc_info.value) - assert "user" in str(exc_info.value) + assert config.alembic_config_file_path == "custom_alembic.ini" + assert config.allow_migration_for_empty_database is False + assert config.additional_parameters == "-x custom_param=value" - def test_build_database_url_with_special_characters(self) -> None: - """Test URL building with special characters in password.""" - db_config = { + def test_database_config_missing_required_fields(self): + """Test that missing required fields raise validation errors.""" + incomplete_config = { "dialect": "postgresql", - "user": "test@user", - "password": "test!@#$%pass", - "host": "localhost", - "port": "5432", - "database": "test-db", + "user": "testuser", + # Missing password, host, port, database, alembic_directory_path } - expected_url = "postgresql://test@user:test!@#$%pass@localhost:5432/test-db" - result = build_database_url(db_config) - - assert result == expected_url + with pytest.raises(ValidationError) as excinfo: + DatabaseConfig(**incomplete_config) + + error_dict = excinfo.value.errors() + required_fields = ["password", "host", "port", "database", "alembic_directory_path"] + + for field in required_fields: + assert any(error["loc"] == (field,) for error in error_dict) + + def test_database_config_invalid_port(self): + """Test validation of port field.""" + # Test port too small + with pytest.raises(ValidationError) as excinfo: + DatabaseConfig( + dialect="postgresql", + user="testuser", + password="testpass", + host="localhost", + port=0, # Invalid: too small + database="testdb", + alembic_directory_path="/app/alembic", + ) + assert "greater than 0" in str(excinfo.value) + + # Test port too large + with pytest.raises(ValidationError) as excinfo: + DatabaseConfig( + dialect="postgresql", + user="testuser", + password="testpass", + host="localhost", + port=65536, # Invalid: too large + database="testdb", + alembic_directory_path="/app/alembic", + ) + assert "less than or equal to 65535" in str(excinfo.value) + + def test_database_config_dialect_validation(self): + """Test dialect validation with supported and unsupported dialects.""" + # Test supported dialect + config = DatabaseConfig( + dialect="postgresql", + user="testuser", + password="testpass", + host="localhost", + port=5432, + database="testdb", + alembic_directory_path="/app/alembic", + ) + assert config.dialect == "postgresql" + + # Test unsupported dialect (should still work but log warning) + config = DatabaseConfig( + dialect="unsupported_db", + user="testuser", + password="testpass", + host="localhost", + port=5432, + database="testdb", + alembic_directory_path="/app/alembic", + ) + assert config.dialect == "unsupported_db" + + def test_additional_parameters_empty_string_handling(self): + """Test that additional_parameters defaults to empty string when not provided.""" + config = DatabaseConfig( + dialect="postgresql", + user="testuser", + password="testpass", + host="localhost", + port=5432, + database="testdb", + alembic_directory_path="/app/alembic", + ) + assert config.additional_parameters == "" + + +class TestMultiDatabaseConfig: + """Test cases for MultiDatabaseConfig model.""" + + def test_valid_multi_database_config(self): + """Test creating a valid multi-database configuration.""" + db_config = DatabaseConfig( + dialect="postgresql", + user="testuser", + password="testpass", + host="localhost", + port=5432, + database="testdb", + alembic_directory_path="/app/alembic", + ) + + config = MultiDatabaseConfig(databases={"main": db_config}) + + assert "main" in config.databases + assert config.databases["main"] == db_config + + def test_multi_database_config_multiple_databases(self): + """Test multi-database configuration with multiple databases.""" + db1 = DatabaseConfig( + dialect="postgresql", + user="user1", + password="pass1", + host="host1", + port=5432, + database="db1", + alembic_directory_path="/app/alembic1", + ) + + db2 = DatabaseConfig( + dialect="mysql", + user="user2", + password="pass2", + host="host2", + port=3306, + database="db2", + alembic_directory_path="/app/alembic2", + ) + + config = MultiDatabaseConfig(databases={"main": db1, "analytics": db2}) + + assert len(config.databases) == 2 + assert "main" in config.databases + assert "analytics" in config.databases + assert config.databases["main"] == db1 + assert config.databases["analytics"] == db2 + + def test_multi_database_config_empty_databases(self): + """Test that empty databases dictionary raises validation error.""" + with pytest.raises(ValidationError) as excinfo: + MultiDatabaseConfig(databases={}) + + assert "At least one database must be configured" in str(excinfo.value) + + def test_multi_database_config_missing_databases(self): + """Test that missing databases field raises validation error.""" + with pytest.raises(ValidationError) as excinfo: + MultiDatabaseConfig() + + error_dict = excinfo.value.errors() + assert any(error["loc"] == ("databases",) for error in error_dict) class TestLoadMultiDatabaseConfig: """Test cases for load_multi_database_config function.""" - def test_load_multi_database_config_success(self) -> None: - """Test successful loading of multi-database configuration.""" - config_data = { + def test_load_valid_config_file(self): + """Test loading a valid configuration file.""" + config_content = { "databases": { "main": { - "alembic_directory_path": "/app/alembic/main", - "alembic_config_file_path": "alembic.ini", "dialect": "postgresql", - "user": "mainuser", - "password": "mainpass", - "host": "main.db.com", - "port": "5432", - "database": "maindb", - "allow_migration_for_empty_database": True, - "additional_parameters": "--verbose", + "user": "testuser", + "password": "testpass", + "host": "localhost", + "port": 5432, + "database": "testdb", + "alembic_directory_path": "/app/alembic", }, - "secondary": { - "alembic_directory_path": "/app/alembic/secondary", - "alembic_config_file_path": "alembic.ini", - "dialect": "postgresql", - "user": "secuser", - "password": "secpass", - "host": "sec.db.com", - "port": "5433", - "database": "secdb", + "analytics": { + "dialect": "mysql", + "user": "analyticsuser", + "password": "analyticspass", + "host": "analytics-host", + "port": 3306, + "database": "analytics", + "alembic_directory_path": "/app/alembic/analytics", "allow_migration_for_empty_database": False, }, } } with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - temp_file = f.name + yaml.dump(config_content, f) + config_path = f.name try: - result = load_multi_database_config(temp_file) - - assert len(result) == 2 - - # Check main database config - main_db = next(db for db in result if db["name"] == "main") - assert main_db["alembic_directory_path"] == "/app/alembic/main" - assert main_db["url"] == "postgresql://mainuser:mainpass@main.db.com:5432/maindb" - assert main_db["allow_migration_for_empty_database"] is True - assert main_db["additional_parameters"] == "--verbose" - - # Check secondary database config - sec_db = next(db for db in result if db["name"] == "secondary") - assert sec_db["alembic_directory_path"] == "/app/alembic/secondary" - assert sec_db["url"] == "postgresql://secuser:secpass@sec.db.com:5433/secdb" - assert sec_db["allow_migration_for_empty_database"] is False - assert sec_db.get("additional_parameters", "") == "" + databases = load_multi_database_config(config_path) + + assert len(databases) == 2 + assert "main" in databases + assert "analytics" in databases + + # Check main database + main_db = databases["main"] + assert main_db.dialect == "postgresql" + assert main_db.user == "testuser" + assert main_db.password == "testpass" + assert main_db.host == "localhost" + assert main_db.port == 5432 + assert main_db.database == "testdb" + assert main_db.alembic_directory_path == "/app/alembic" + assert main_db.allow_migration_for_empty_database is True # default + assert main_db.url == "postgresql://testuser:testpass@localhost:5432/testdb" + + # Check analytics database + analytics_db = databases["analytics"] + assert analytics_db.dialect == "mysql" + assert analytics_db.user == "analyticsuser" + assert analytics_db.allow_migration_for_empty_database is False + assert analytics_db.url == "mysql://analyticsuser:analyticspass@analytics-host:3306/analytics" finally: - os.unlink(temp_file) + Path(config_path).unlink() - def test_load_multi_database_config_missing_databases_key(self) -> None: - """Test that missing 'databases' key raises ValueError.""" - config_data = {"some_other_key": {"value": "test"}} + def test_load_single_database_config(self): + """Test loading a configuration with a single database.""" + config_content = { + "databases": { + "main": { + "dialect": "postgresql", + "user": "testuser", + "password": "testpass", + "host": "localhost", + "port": 5432, + "database": "testdb", + "alembic_directory_path": "/app/alembic", + } + } + } with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - temp_file = f.name + yaml.dump(config_content, f) + config_path = f.name try: - with pytest.raises(ValueError) as exc_info: - load_multi_database_config(temp_file) + databases = load_multi_database_config(config_path) + + assert len(databases) == 1 + assert "main" in databases + main_db = databases["main"] + assert main_db.dialect == "postgresql" - assert "Configuration must contain 'databases' key" in str(exc_info.value) finally: - os.unlink(temp_file) + Path(config_path).unlink() + + def test_load_nonexistent_file(self): + """Test loading a configuration file that doesn't exist.""" + with pytest.raises(FileNotFoundError) as excinfo: + load_multi_database_config("/nonexistent/config.yaml") - def test_load_multi_database_config_databases_not_dict(self) -> None: - """Test that non-dict 'databases' value raises ValueError.""" - config_data = {"databases": ["not", "a", "dict"]} + assert "Configuration file not found" in str(excinfo.value) + + def test_load_invalid_yaml(self): + """Test loading a file with invalid YAML.""" + invalid_yaml = "databases:\n main:\n dialect: postgresql\n invalid: yaml: content:" with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - temp_file = f.name + f.write(invalid_yaml) + config_path = f.name try: - with pytest.raises(ValueError) as exc_info: - load_multi_database_config(temp_file) + with pytest.raises(ValueError) as excinfo: + load_multi_database_config(config_path) + + assert "Invalid YAML in configuration file" in str(excinfo.value) - assert "'databases' must be a dictionary" in str(exc_info.value) finally: - os.unlink(temp_file) + Path(config_path).unlink() - def test_load_multi_database_config_missing_required_components(self) -> None: - """Test that missing database components raise ValueError.""" - config_data = { + def test_load_config_missing_required_fields(self): + """Test loading a configuration with missing required fields.""" + config_content = { "databases": { - "incomplete": { - "alembic_directory_path": "/app/alembic", + "main": { "dialect": "postgresql", - "user": "user", - # Missing password, host, port, database + "user": "testuser", + # Missing required fields: password, host, port, database, alembic_directory_path } } } with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - temp_file = f.name + yaml.dump(config_content, f) + config_path = f.name try: - with pytest.raises(ValueError) as exc_info: - load_multi_database_config(temp_file) + with pytest.raises(ValueError) as excinfo: + load_multi_database_config(config_path) + + assert "Invalid configuration" in str(excinfo.value) - assert "Missing required components" in str(exc_info.value) finally: - os.unlink(temp_file) + Path(config_path).unlink() + + def test_load_config_empty_databases(self): + """Test loading a configuration with empty databases.""" + config_content = {"databases": {}} + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(config_content, f) + config_path = f.name - def test_load_multi_database_config_file_not_found(self) -> None: - """Test that missing config file raises FileNotFoundError.""" - non_existent_file = "/path/that/does/not/exist.yaml" + try: + with pytest.raises(ValueError) as excinfo: + load_multi_database_config(config_path) - with pytest.raises(FileNotFoundError) as exc_info: - load_multi_database_config(non_existent_file) + assert "Invalid configuration" in str(excinfo.value) - assert f"Configuration file not found: {non_existent_file}" in str(exc_info.value) + finally: + Path(config_path).unlink() - def test_load_multi_database_config_invalid_yaml(self) -> None: - """Test that invalid YAML raises ValueError.""" - invalid_yaml = """ - databases: - main: - key: value - invalid: [unclosed bracket - """ + def test_load_config_no_databases_key(self): + """Test loading a configuration without 'databases' key.""" + config_content = {"invalid_key": "value"} with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write(invalid_yaml) - temp_file = f.name + yaml.dump(config_content, f) + config_path = f.name try: - with pytest.raises(ValueError) as exc_info: - load_multi_database_config(temp_file) + with pytest.raises(ValueError) as excinfo: + load_multi_database_config(config_path) + + assert "Invalid configuration" in str(excinfo.value) - assert "Invalid YAML in configuration file" in str(exc_info.value) finally: - os.unlink(temp_file) + Path(config_path).unlink() - def test_load_multi_database_config_minimal_config(self) -> None: - """Test loading with minimal required configuration.""" - config_data = { + def test_load_config_with_all_optional_fields(self): + """Test loading a configuration with all optional fields specified.""" + config_content = { "databases": { - "minimal": { + "main": { + "dialect": "postgresql", + "user": "testuser", + "password": "testpass", + "host": "localhost", + "port": 5432, + "database": "testdb", "alembic_directory_path": "/app/alembic", - "alembic_config_file_path": "alembic.ini", + "alembic_config_file_path": "custom_alembic.ini", + "allow_migration_for_empty_database": False, + "additional_parameters": "-x custom=value", + } + } + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(config_content, f) + config_path = f.name + + try: + databases = load_multi_database_config(config_path) + + assert len(databases) == 1 + db = databases["main"] + assert db.alembic_config_file_path == "custom_alembic.ini" + assert db.allow_migration_for_empty_database is False + assert db.additional_parameters == "-x custom=value" + + finally: + Path(config_path).unlink() + + @patch("chartreuse.config_loader.logger") + def test_load_config_logs_info(self, mock_logger): + """Test that loading a valid config logs appropriate info messages.""" + config_content = { + "databases": { + "main": { "dialect": "postgresql", - "user": "user", - "password": "pass", + "user": "testuser", + "password": "testpass", "host": "localhost", - "port": "5432", - "database": "db", + "port": 5432, + "database": "testdb", + "alembic_directory_path": "/app/alembic", + }, + "analytics": { + "dialect": "mysql", + "user": "analyticsuser", + "password": "analyticspass", + "host": "analytics-host", + "port": 3306, + "database": "analytics", + "alembic_directory_path": "/app/alembic/analytics", + }, + } + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(config_content, f) + config_path = f.name + + try: + load_multi_database_config(config_path) + + # Check that info was logged about loaded databases + mock_logger.info.assert_called_once() + log_call = mock_logger.info.call_args[0] + # The logger uses % formatting, so check format string and args separately + assert "Loaded configuration for %d database(s): %s" == log_call[0] + assert log_call[1] == 2 + # Check that both database names are present (order may vary) + database_names = log_call[2] + assert "main" in database_names + assert "analytics" in database_names + + finally: + Path(config_path).unlink() + + @patch("chartreuse.config_loader.logger") + def test_unsupported_dialect_logs_warning(self, mock_logger): + """Test that using an unsupported dialect logs a warning.""" + config_content = { + "databases": { + "main": { + "dialect": "unsupported_dialect", + "user": "testuser", + "password": "testpass", + "host": "localhost", + "port": 5432, + "database": "testdb", + "alembic_directory_path": "/app/alembic", } } } with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - temp_file = f.name + yaml.dump(config_content, f) + config_path = f.name try: - result = load_multi_database_config(temp_file) + load_multi_database_config(config_path) - assert len(result) == 1 - db_config = result[0] - assert db_config["name"] == "minimal" - assert db_config["url"] == "postgresql://user:pass@localhost:5432/db" - assert db_config.get("allow_migration_for_empty_database", False) is False - assert db_config.get("additional_parameters", "") == "" + # Check that warning was logged about unsupported dialect + mock_logger.warning.assert_called_once() + log_call = mock_logger.warning.call_args[0] + assert "unsupported_dialect" in log_call[1] + assert "might not be supported" in log_call[0] finally: - os.unlink(temp_file) + Path(config_path).unlink() diff --git a/src/chartreuse/utils/alembic_migration_helper.py b/src/chartreuse/utils/alembic_migration_helper.py index 542f4f4..27c8c31 100755 --- a/src/chartreuse/utils/alembic_migration_helper.py +++ b/src/chartreuse/utils/alembic_migration_helper.py @@ -1,6 +1,7 @@ import logging import os import re +from configparser import ConfigParser from subprocess import SubprocessError from time import sleep, time @@ -19,10 +20,11 @@ def __init__( alembic_directory_path: str = "/app/alembic", alembic_config_file_path: str = "alembic.ini", database_url: str, + alembic_section_name: str, additional_parameters: str = "", allow_migration_for_empty_database: bool = False, configure: bool = True, - alembic_section_name: str | None = None, + # skip_db_checks is used for testing purposes only skip_db_checks: bool = False, ): if not database_url: @@ -44,49 +46,31 @@ def __init__( if configure: self._configure() - + # skip_db_checks is used for testing purposes only if not skip_db_checks: self.is_migration_needed = self._check_migration_needed() else: self.is_migration_needed = False def _configure(self) -> None: - with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}") as f: - content = f.read() - - if self.alembic_section_name: - # Multi-database configuration: update specific section - # Find the section and update sqlalchemy.url within that section only - section_start = rf"\[{re.escape(self.alembic_section_name)}\]" - section_pattern = ( - rf"({section_start}.*?)" - rf"(sqlalchemy\.url\s*=\s*[^\n]*)" - rf"(.*?)(?=\n\[|\Z)" - ) - content_new = re.sub( - section_pattern, - rf"\1sqlalchemy.url = {self.database_url}\3", - content, - flags=re.DOTALL | re.M, - ) - else: - # Single database configuration: replace any sqlalchemy.url - content_new = re.sub( - "(sqlalchemy.url.*=.*){1}", - rf"sqlalchemy.url={self.database_url}", - content, - flags=re.M, - ) - - if content != content_new: - with open(f"{self.alembic_directory_path}/{self.alembic_config_file_path}", "w") as f: - f.write(content_new) - config_type = ( - f"section {self.alembic_section_name}" if self.alembic_section_name else "default configuration" - ) - logger.info(f"alembic.ini was configured for {config_type}.") - else: - raise SubprocessError("configuration of alembic.ini has failed (alembic.ini is unchanged)") + config_path = f"{self.alembic_directory_path}/{self.alembic_config_file_path}" + + # Read the configuration file + config = ConfigParser() + config.read(config_path) + + # Multi-database configuration: update specific section + if not config.has_section(self.alembic_section_name): + raise ValueError(f"Section '{self.alembic_section_name}' not found in {self.alembic_config_file_path}") + + # Update the sqlalchemy.url in the specific section + config.set(self.alembic_section_name, "sqlalchemy.url", self.database_url) + + # Write the updated configuration back to the file + with open(config_path, "w") as f: + config.write(f) + + logger.info("alembic.ini was configured for section %s.", self.alembic_section_name) def _wait_postgres_is_configured(self) -> None: """ @@ -123,7 +107,7 @@ def _wait_postgres_is_configured(self) -> None: return except Exception as e: # TODO: Learn about exceptions that should be caught here, otherwise we'll wait for nothing - logger.info(f"Caught: {e}") + logger.info("Caught: %s", e) logger.info( "Waiting for the postgres-operator to create the user wiremind_owner_user" " (that alembic and I use) and to set default privileges..." @@ -139,7 +123,7 @@ def _get_table_list(self) -> list[str]: def is_postgres_empty(self) -> bool: table_list = self._get_table_list() - logger.info(f"Tables in the database: {table_list}") + logger.info("Tables in the database: %s", table_list) # Don't count "alembic" table table_name = "alembic_version" if table_name in table_list: