From 9c12034676f603472f96275b7d83197af19da15e Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Wed, 28 Jan 2026 21:29:27 +0100 Subject: [PATCH 01/13] fix: Update Jenkinsfile with correct image path and auth --- Jenkinsfile | 108 ++++++++++++++++++++++++++++------------------------ 1 file changed, 59 insertions(+), 49 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 438b2df..97ff2b1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,66 +1,76 @@ pipeline { agent { - docker { - image "ghcr.io/opsguild/metal-deploy:latest" - // Mount docker socket if performing docker/k8s deployments - args '-v /var/run/docker.sock:/var/run/docker.sock' + docker { + image "ghcr.io/opsguild/metaldeploy:latest" + registryUrl "https://ghcr.io" + registryCredentialsId "github-registry-auth" + // Reset entrypoint to allow running shell commands + args '--entrypoint="" -v /var/run/docker.sock:/var/run/docker.sock' } } - parameters { - string(name: 'GIT_URL', defaultValue: '', description: 'Git repository URL to clone and deploy') - choice(name: 'GIT_AUTH_METHOD', choices: ['token', 'ssh', 'none'], description: 'Git authentication method') - password(name: 'GIT_TOKEN', defaultValue: '', description: 'GitHub token (if using token auth)') - string(name: 'GIT_USER', defaultValue: '', description: 'GitHub username (if using token auth)') - string(name: 'ENVIRONMENT', defaultValue: 'dev', description: 'Deployment environment (dev, staging, prod)') - string(name: 'REMOTE_HOST', defaultValue: '', description: 'SSH remote host IP or domain') - string(name: 'REMOTE_USER', defaultValue: 'root', description: 'SSH remote user') - password(name: 'SSH_KEY', defaultValue: '', description: 'SSH private key (base64 encoded or raw)') - choice(name: 'DEPLOYMENT_TYPE', choices: ['baremetal', 'docker', 'k8s'], description: 'Deployment type') - } - environment { - // Map Jenkins parameters to environment variables used by the script - GIT_URL = "${params.GIT_URL}" - GIT_AUTH_METHOD = "${params.GIT_AUTH_METHOD}" - GIT_TOKEN = "${params.GIT_TOKEN}" - GIT_USER = "${params.GIT_USER}" - ENVIRONMENT = "${params.ENVIRONMENT}" - REMOTE_HOST = "${params.REMOTE_HOST}" - REMOTE_USER = "${params.REMOTE_USER}" - SSH_KEY = "${params.SSH_KEY}" - DEPLOYMENT_TYPE = "${params.DEPLOYMENT_TYPE}" - - // Environment File Generation examples: - // Variables starting with ENV_ will automatically be converted to .env files - ENV_FILES_GENERATE = "true" - // ENV_APP_DATABASE_URL = "postgres://user:pass@host:5432/db" // Map from credentials + GIT_AUTH_METHOD = 'token' + USE_SUDO = 'true' + DEPLOYMENT_TYPE= 'baremetal' + ENV_FILES_GENERATE = 'true' + ENV_FILES_STRUCTURE = 'auto' + ENV_FILES_CREATE_ROOT = 'false' + ENV_FILES_FORMAT = 'auto' } stages { - stage('Deploy') { + // --- STAGING --- + stage('Deploy Staging') { + when { branch 'staging' } steps { - script { - // Automatically map ALL Jenkins parameters to environment variables - // This means any parameter you define (e.g. ENV_APP_DB) - // is automatically available to the script without manual mapping. - def paramEnv = params.collect { k, v -> "${k}=${v}" } - - withEnv(paramEnv) { - echo "Starting deployment to ${env.REMOTE_HOST} (${env.ENVIRONMENT})..." - sh "python main.py" - } + withCredentials([ + // Staging Secrets (Use unique IDs!) + string(credentialsId: 'git-token', variable: 'GIT_TOKEN'), + string(credentialsId: 'staging-remote-pass', variable: 'REMOTE_PASSWORD'), + string(credentialsId: 'staging-remote-host', variable: 'REMOTE_HOST'), + + file(credentialsId: 'staging-app-env', variable: 'ENV_APP'), + file(credentialsId: 'staging-atlas-env', variable: 'ENV_ATLAS'), + file(credentialsId: 'staging-database-env', variable: 'ENV_DATABASE'), + file(credentialsId: 'staging-minio-env', variable: 'ENV_MINIO'), + file(credentialsId: 'staging-redis-env', variable: 'ENV_REDIS') + ]) { + script { + def cmd = 'export env=staging && make up && make migrate' + withEnv(["ENVIRONMENT=staging", "DEPLOY_COMMAND=${cmd}"]) { + // Use absolute path to the tool inside the container + sh "python /app/main.py" + } + } } } } - } - post { - success { - echo "Deployment successful!" - } - failure { - echo "Deployment failed!" + // --- PRODUCTION --- + stage('Deploy Production') { + when { branch 'main' } + steps { + withCredentials([ + // Production Secrets + string(credentialsId: 'git-token', variable: 'GIT_TOKEN'), + string(credentialsId: 'prod-remote-pass', variable: 'REMOTE_PASSWORD'), + string(credentialsId: 'prod-remote-host', variable: 'REMOTE_HOST'), + + file(credentialsId: 'prod-app-env', variable: 'ENV_APP'), + file(credentialsId: 'prod-atlas-env', variable: 'ENV_ATLAS'), + file(credentialsId: 'prod-database-env', variable: 'ENV_DATABASE'), + file(credentialsId: 'prod-minio-env', variable: 'ENV_MINIO'), + file(credentialsId: 'prod-redis-env', variable: 'ENV_REDIS') + ]) { + script { + def cmd = 'export env=prod && make up && make migrate' + withEnv(["ENVIRONMENT=prod", "DEPLOY_COMMAND=${cmd}"]) { + sh "python /app/main.py" + } + } + } + } } } } From f0445eafc1ea60fa077e1cbcd312e9285f1bbfd3 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Wed, 28 Jan 2026 22:08:37 +0100 Subject: [PATCH 02/13] fix(env_manager): Prevent incorrect pattern detection for config variables --- Jenkinsfile | 6 ++++-- src/env_manager.py | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 97ff2b1..7f79933 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ pipeline { agent { - docker { + docker { image "ghcr.io/opsguild/metaldeploy:latest" registryUrl "https://ghcr.io" registryCredentialsId "github-registry-auth" @@ -11,6 +11,8 @@ pipeline { environment { GIT_AUTH_METHOD = 'token' + GIT_URL = "${env.GIT_URL}" + GIT_USER = 'hordunlarmy' USE_SUDO = 'true' DEPLOYMENT_TYPE= 'baremetal' ENV_FILES_GENERATE = 'true' @@ -29,7 +31,7 @@ pipeline { string(credentialsId: 'git-token', variable: 'GIT_TOKEN'), string(credentialsId: 'staging-remote-pass', variable: 'REMOTE_PASSWORD'), string(credentialsId: 'staging-remote-host', variable: 'REMOTE_HOST'), - + file(credentialsId: 'staging-app-env', variable: 'ENV_APP'), file(credentialsId: 'staging-atlas-env', variable: 'ENV_ATLAS'), file(credentialsId: 'staging-database-env', variable: 'ENV_DATABASE'), diff --git a/src/env_manager.py b/src/env_manager.py index 08fb3a8..1924352 100644 --- a/src/env_manager.py +++ b/src/env_manager.py @@ -63,6 +63,10 @@ def detect_file_patterns(all_env_vars: Dict[str, str], structure: str) -> List[s patterns = set() for var_name in all_env_vars.keys(): + # Exclude config variables + if var_name.startswith("ENV_FILES_"): + continue + match = re.match(r"^ENV_[A-Z0-9_]*_([A-Z]+)_", var_name) if match: filename = match.group(1).lower() From eba78109bb84e43b3c1a4edc4b0d76f8d616c4dd Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:29:03 +0100 Subject: [PATCH 03/13] test(test-suite): update test suite with new test cases --- tests/README.md | 108 ++----- tests/conftest.py | 18 ++ .../auto_dev/.envs/dev/.env.api | 1 + .../auto_dev/.envs/dev/.env.app | 3 + .../auto_dev/.envs/dev/.env.database | 2 + .../auto_dev/.envs/dev/.env.elastic | 1 + .../auto_dev/.envs/dev/.env.kafka | 1 + .../auto_dev/.envs/dev/.env.minio | 1 + .../auto_dev/.envs/dev/.env.redis | 2 + .../generated_envs/auto_dev/.envs/dev/.env.s3 | 1 + tests/generated_envs/create_root_agg/.env | 12 + .../create_root_agg/.envs/dev/.env.api | 1 + .../create_root_agg/.envs/dev/.env.app | 3 + .../create_root_agg/.envs/dev/.env.database | 2 + .../create_root_agg/.envs/dev/.env.elastic | 1 + .../create_root_agg/.envs/dev/.env.kafka | 1 + .../create_root_agg/.envs/dev/.env.minio | 1 + .../create_root_agg/.envs/dev/.env.redis | 2 + .../create_root_agg/.envs/dev/.env.s3 | 1 + tests/generated_envs/custom_path_abs/.env.api | 1 + tests/generated_envs/custom_path_abs/.env.app | 3 + .../custom_path_abs/.env.database | 2 + .../custom_path_abs/.env.elastic | 1 + .../generated_envs/custom_path_abs/.env.kafka | 1 + .../generated_envs/custom_path_abs/.env.minio | 1 + .../generated_envs/custom_path_abs/.env.redis | 2 + tests/generated_envs/custom_path_abs/.env.s3 | 1 + .../custom_path_rel/my_configs/.env.api | 1 + .../custom_path_rel/my_configs/.env.app | 3 + .../custom_path_rel/my_configs/.env.database | 2 + .../custom_path_rel/my_configs/.env.elastic | 1 + .../custom_path_rel/my_configs/.env.kafka | 1 + .../custom_path_rel/my_configs/.env.minio | 1 + .../custom_path_rel/my_configs/.env.redis | 2 + .../custom_path_rel/my_configs/.env.s3 | 1 + .../explicit_patterns/.env.only_app | 1 + .../explicit_patterns/.env.only_db | 1 + .../generated_envs/file_path_secret/.env.api | 1 + .../generated_envs/file_path_secret/.env.app | 3 + .../file_path_secret/.env.database | 2 + .../file_path_secret/.env.elastic | 1 + .../file_path_secret/.env.kafka | 1 + .../file_path_secret/.env.minio | 1 + .../file_path_secret/.env.redis | 2 + tests/generated_envs/file_path_secret/.env.s3 | 2 + tests/generated_envs/flat_staging/.env.api | 1 + tests/generated_envs/flat_staging/.env.app | 3 + .../generated_envs/flat_staging/.env.database | 2 + .../generated_envs/flat_staging/.env.elastic | 1 + tests/generated_envs/flat_staging/.env.kafka | 1 + tests/generated_envs/flat_staging/.env.minio | 1 + tests/generated_envs/flat_staging/.env.redis | 2 + tests/generated_envs/flat_staging/.env.s3 | 1 + tests/generated_envs/formats_parsing/.env.api | 1 + tests/generated_envs/formats_parsing/.env.app | 4 + .../formats_parsing/.env.database | 2 + .../formats_parsing/.env.elastic | 1 + .../generated_envs/formats_parsing/.env.kafka | 1 + .../generated_envs/formats_parsing/.env.minio | 1 + .../generated_envs/formats_parsing/.env.redis | 2 + tests/generated_envs/formats_parsing/.env.s3 | 1 + .../multi_env_nested/.envs/dev/.env.api | 1 + .../multi_env_nested/.envs/dev/.env.app | 3 + .../multi_env_nested/.envs/dev/.env.database | 2 + .../multi_env_nested/.envs/dev/.env.elastic | 1 + .../multi_env_nested/.envs/dev/.env.kafka | 1 + .../multi_env_nested/.envs/dev/.env.minio | 1 + .../multi_env_nested/.envs/dev/.env.redis | 2 + .../multi_env_nested/.envs/dev/.env.s3 | 1 + .../multi_env_nested/.envs/prod/.env.api | 1 + .../multi_env_nested/.envs/prod/.env.app | 4 + .../multi_env_nested/.envs/prod/.env.database | 2 + .../multi_env_nested/.envs/prod/.env.elastic | 1 + .../multi_env_nested/.envs/prod/.env.kafka | 1 + .../multi_env_nested/.envs/prod/.env.minio | 1 + .../multi_env_nested/.envs/prod/.env.redis | 2 + .../multi_env_nested/.envs/prod/.env.s3 | 1 + .../multi_env_nested/.envs/staging/.env.api | 1 + .../multi_env_nested/.envs/staging/.env.app | 3 + .../.envs/staging/.env.database | 2 + .../.envs/staging/.env.elastic | 1 + .../multi_env_nested/.envs/staging/.env.kafka | 1 + .../multi_env_nested/.envs/staging/.env.minio | 1 + .../multi_env_nested/.envs/staging/.env.redis | 2 + .../multi_env_nested/.envs/staging/.env.s3 | 1 + tests/generated_envs/single_prod/.env | 13 + tests/integration/Dockerfile.test-ssh | 29 ++ tests/integration/conftest.py | 113 +++++++ tests/integration/docker-compose.yml | 11 + .../auto_dev/.envs/dev/.env.api | 1 + .../auto_dev/.envs/dev/.env.app | 3 + .../auto_dev/.envs/dev/.env.database | 2 + .../auto_dev/.envs/dev/.env.elastic | 1 + .../auto_dev/.envs/dev/.env.kafka | 1 + .../auto_dev/.envs/dev/.env.minio | 1 + .../auto_dev/.envs/dev/.env.redis | 2 + .../generated_envs/auto_dev/.envs/dev/.env.s3 | 1 + .../generated_envs/create_root_agg/.env | 12 + .../create_root_agg/.envs/dev/.env.api | 1 + .../create_root_agg/.envs/dev/.env.app | 3 + .../create_root_agg/.envs/dev/.env.database | 2 + .../create_root_agg/.envs/dev/.env.elastic | 1 + .../create_root_agg/.envs/dev/.env.kafka | 1 + .../create_root_agg/.envs/dev/.env.minio | 1 + .../create_root_agg/.envs/dev/.env.redis | 2 + .../create_root_agg/.envs/dev/.env.s3 | 1 + .../generated_envs/custom_path_abs/.env.api | 1 + .../generated_envs/custom_path_abs/.env.app | 3 + .../custom_path_abs/.env.database | 2 + .../custom_path_abs/.env.elastic | 1 + .../generated_envs/custom_path_abs/.env.kafka | 1 + .../generated_envs/custom_path_abs/.env.minio | 1 + .../generated_envs/custom_path_abs/.env.redis | 2 + .../generated_envs/custom_path_abs/.env.s3 | 1 + .../custom_path_rel/my_configs/.env.api | 1 + .../custom_path_rel/my_configs/.env.app | 3 + .../custom_path_rel/my_configs/.env.database | 2 + .../custom_path_rel/my_configs/.env.elastic | 1 + .../custom_path_rel/my_configs/.env.kafka | 1 + .../custom_path_rel/my_configs/.env.minio | 1 + .../custom_path_rel/my_configs/.env.redis | 2 + .../custom_path_rel/my_configs/.env.s3 | 1 + .../explicit_patterns/.env.only_app | 1 + .../explicit_patterns/.env.only_db | 1 + .../generated_envs/file_path_secret/.env.api | 1 + .../generated_envs/file_path_secret/.env.app | 3 + .../file_path_secret/.env.database | 2 + .../file_path_secret/.env.elastic | 1 + .../file_path_secret/.env.kafka | 1 + .../file_path_secret/.env.minio | 1 + .../file_path_secret/.env.redis | 2 + .../generated_envs/file_path_secret/.env.s3 | 2 + .../generated_envs/flat_staging/.env.api | 1 + .../generated_envs/flat_staging/.env.app | 3 + .../generated_envs/flat_staging/.env.database | 2 + .../generated_envs/flat_staging/.env.elastic | 1 + .../generated_envs/flat_staging/.env.kafka | 1 + .../generated_envs/flat_staging/.env.minio | 1 + .../generated_envs/flat_staging/.env.redis | 2 + .../generated_envs/flat_staging/.env.s3 | 1 + .../generated_envs/formats_parsing/.env.api | 1 + .../generated_envs/formats_parsing/.env.app | 4 + .../formats_parsing/.env.database | 2 + .../formats_parsing/.env.elastic | 1 + .../generated_envs/formats_parsing/.env.kafka | 1 + .../generated_envs/formats_parsing/.env.minio | 1 + .../generated_envs/formats_parsing/.env.redis | 2 + .../generated_envs/formats_parsing/.env.s3 | 1 + .../multi_env_nested/.envs/dev/.env.api | 1 + .../multi_env_nested/.envs/dev/.env.app | 3 + .../multi_env_nested/.envs/dev/.env.database | 2 + .../multi_env_nested/.envs/dev/.env.elastic | 1 + .../multi_env_nested/.envs/dev/.env.kafka | 1 + .../multi_env_nested/.envs/dev/.env.minio | 1 + .../multi_env_nested/.envs/dev/.env.redis | 2 + .../multi_env_nested/.envs/dev/.env.s3 | 1 + .../multi_env_nested/.envs/prod/.env.api | 1 + .../multi_env_nested/.envs/prod/.env.app | 4 + .../multi_env_nested/.envs/prod/.env.database | 2 + .../multi_env_nested/.envs/prod/.env.elastic | 1 + .../multi_env_nested/.envs/prod/.env.kafka | 1 + .../multi_env_nested/.envs/prod/.env.minio | 1 + .../multi_env_nested/.envs/prod/.env.redis | 2 + .../multi_env_nested/.envs/prod/.env.s3 | 1 + .../multi_env_nested/.envs/staging/.env.api | 1 + .../multi_env_nested/.envs/staging/.env.app | 3 + .../.envs/staging/.env.database | 2 + .../.envs/staging/.env.elastic | 1 + .../multi_env_nested/.envs/staging/.env.kafka | 1 + .../multi_env_nested/.envs/staging/.env.minio | 1 + .../multi_env_nested/.envs/staging/.env.redis | 2 + .../multi_env_nested/.envs/staging/.env.s3 | 1 + .../generated_envs/single_prod/.env | 13 + tests/integration/test_integration_full.py | 293 ++++++++++++++++++ 174 files changed, 794 insertions(+), 78 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.api create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.app create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.database create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.elastic create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.kafka create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.minio create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.redis create mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.s3 create mode 100644 tests/generated_envs/create_root_agg/.env create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.api create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.app create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.database create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.elastic create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.kafka create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.minio create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.redis create mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.s3 create mode 100644 tests/generated_envs/custom_path_abs/.env.api create mode 100644 tests/generated_envs/custom_path_abs/.env.app create mode 100644 tests/generated_envs/custom_path_abs/.env.database create mode 100644 tests/generated_envs/custom_path_abs/.env.elastic create mode 100644 tests/generated_envs/custom_path_abs/.env.kafka create mode 100644 tests/generated_envs/custom_path_abs/.env.minio create mode 100644 tests/generated_envs/custom_path_abs/.env.redis create mode 100644 tests/generated_envs/custom_path_abs/.env.s3 create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.api create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.app create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.database create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.elastic create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.kafka create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.minio create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.redis create mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.s3 create mode 100644 tests/generated_envs/explicit_patterns/.env.only_app create mode 100644 tests/generated_envs/explicit_patterns/.env.only_db create mode 100644 tests/generated_envs/file_path_secret/.env.api create mode 100644 tests/generated_envs/file_path_secret/.env.app create mode 100644 tests/generated_envs/file_path_secret/.env.database create mode 100644 tests/generated_envs/file_path_secret/.env.elastic create mode 100644 tests/generated_envs/file_path_secret/.env.kafka create mode 100644 tests/generated_envs/file_path_secret/.env.minio create mode 100644 tests/generated_envs/file_path_secret/.env.redis create mode 100644 tests/generated_envs/file_path_secret/.env.s3 create mode 100644 tests/generated_envs/flat_staging/.env.api create mode 100644 tests/generated_envs/flat_staging/.env.app create mode 100644 tests/generated_envs/flat_staging/.env.database create mode 100644 tests/generated_envs/flat_staging/.env.elastic create mode 100644 tests/generated_envs/flat_staging/.env.kafka create mode 100644 tests/generated_envs/flat_staging/.env.minio create mode 100644 tests/generated_envs/flat_staging/.env.redis create mode 100644 tests/generated_envs/flat_staging/.env.s3 create mode 100644 tests/generated_envs/formats_parsing/.env.api create mode 100644 tests/generated_envs/formats_parsing/.env.app create mode 100644 tests/generated_envs/formats_parsing/.env.database create mode 100644 tests/generated_envs/formats_parsing/.env.elastic create mode 100644 tests/generated_envs/formats_parsing/.env.kafka create mode 100644 tests/generated_envs/formats_parsing/.env.minio create mode 100644 tests/generated_envs/formats_parsing/.env.redis create mode 100644 tests/generated_envs/formats_parsing/.env.s3 create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.api create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.app create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.database create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.minio create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.redis create mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.api create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.app create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.database create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.minio create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.redis create mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.api create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.app create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.database create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.minio create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.redis create mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 create mode 100644 tests/generated_envs/single_prod/.env create mode 100644 tests/integration/Dockerfile.test-ssh create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/docker-compose.yml create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.api create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.app create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.database create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.elastic create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.kafka create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.minio create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis create mode 100644 tests/integration/generated_envs/auto_dev/.envs/dev/.env.s3 create mode 100644 tests/integration/generated_envs/create_root_agg/.env create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.api create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.app create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.database create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.elastic create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.kafka create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.minio create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.redis create mode 100644 tests/integration/generated_envs/create_root_agg/.envs/dev/.env.s3 create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.api create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.app create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.database create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.elastic create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.kafka create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.minio create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.redis create mode 100644 tests/integration/generated_envs/custom_path_abs/.env.s3 create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.api create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.app create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.database create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.elastic create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.kafka create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.minio create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.redis create mode 100644 tests/integration/generated_envs/custom_path_rel/my_configs/.env.s3 create mode 100644 tests/integration/generated_envs/explicit_patterns/.env.only_app create mode 100644 tests/integration/generated_envs/explicit_patterns/.env.only_db create mode 100644 tests/integration/generated_envs/file_path_secret/.env.api create mode 100644 tests/integration/generated_envs/file_path_secret/.env.app create mode 100644 tests/integration/generated_envs/file_path_secret/.env.database create mode 100644 tests/integration/generated_envs/file_path_secret/.env.elastic create mode 100644 tests/integration/generated_envs/file_path_secret/.env.kafka create mode 100644 tests/integration/generated_envs/file_path_secret/.env.minio create mode 100644 tests/integration/generated_envs/file_path_secret/.env.redis create mode 100644 tests/integration/generated_envs/file_path_secret/.env.s3 create mode 100644 tests/integration/generated_envs/flat_staging/.env.api create mode 100644 tests/integration/generated_envs/flat_staging/.env.app create mode 100644 tests/integration/generated_envs/flat_staging/.env.database create mode 100644 tests/integration/generated_envs/flat_staging/.env.elastic create mode 100644 tests/integration/generated_envs/flat_staging/.env.kafka create mode 100644 tests/integration/generated_envs/flat_staging/.env.minio create mode 100644 tests/integration/generated_envs/flat_staging/.env.redis create mode 100644 tests/integration/generated_envs/flat_staging/.env.s3 create mode 100644 tests/integration/generated_envs/formats_parsing/.env.api create mode 100644 tests/integration/generated_envs/formats_parsing/.env.app create mode 100644 tests/integration/generated_envs/formats_parsing/.env.database create mode 100644 tests/integration/generated_envs/formats_parsing/.env.elastic create mode 100644 tests/integration/generated_envs/formats_parsing/.env.kafka create mode 100644 tests/integration/generated_envs/formats_parsing/.env.minio create mode 100644 tests/integration/generated_envs/formats_parsing/.env.redis create mode 100644 tests/integration/generated_envs/formats_parsing/.env.s3 create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.api create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.app create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.database create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.elastic create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.kafka create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.minio create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.redis create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.s3 create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.api create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.app create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.database create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.elastic create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.kafka create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.minio create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.redis create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.s3 create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.api create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.app create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.database create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.elastic create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.kafka create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.minio create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.redis create mode 100644 tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.s3 create mode 100644 tests/integration/generated_envs/single_prod/.env create mode 100644 tests/integration/test_integration_full.py diff --git a/tests/README.md b/tests/README.md index 769713d..a1fffcf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,98 +1,50 @@ -# Testing Guide +# MetalDeploy Test Suite -This directory contains tests for the MetalDeploy Action. +This directory is organized into unit and integration tests. -## Running Tests - -### Quick Start (Using Make) +## Structure -```bash -# Install test dependencies -make install-dev - -# Run all tests -make test - -# Run only fast unit tests -make test-unit - -# Run with coverage report -make test-coverage -``` +- `tests/unit/`: Fast unit tests that mock external dependencies (SSH, Git, etc.). +- `tests/integration/`: Docker-based integration tests that simulate a real SSH deployment environment. -### Manual Setup - -#### Install Test Dependencies - -```bash -# Install Poetry (if not already installed) -curl -sSL https://install.python-poetry.org | python3 - - -# Install dependencies -poetry install -``` - -#### Run All Tests - -```bash -poetry run pytest -``` - -### Run Only Unit Tests (Fast) - -```bash -poetry run pytest -m "not integration and not slow" -``` - -### Run with Coverage +## Running Tests +### All Tests ```bash -poetry run pytest --cov=deploy --cov-report=html +make test +# OR +pytest tests/ -v -s ``` -Then open `htmlcov/index.html` in your browser to see coverage report. - -### Run Specific Test File - +### Unit Tests ```bash -poetry run pytest tests/test_deploy.py +make test-unit +# OR +pytest tests/unit/ -v ``` -### Run Specific Test - +### Integration Tests ```bash -poetry run pytest tests/test_deploy.py::TestRunCommand::test_run_command_with_sudo +pytest tests/integration/ -v -s ``` -## Test Structure +### Why `-s` is Required -- **test_deploy.py** - Unit tests for core functionality (mocked) -- **test_integration.py** - Integration tests (require real infrastructure, skipped by default) +Pytest's default output capture mechanism interferes with the SSH connections used by Fabric/Paramiko. The `-s` flag (`--capture=no`) disables this capture, allowing the SSH connections to work properly. -## Writing New Tests +## Test Suite -1. Create test functions starting with `test_` -2. Use `@pytest.mark.unit` for fast unit tests -3. Use `@pytest.mark.integration` for integration tests -4. Use `@pytest.mark.slow` for tests that take a long time -5. Mock external dependencies (SSH connections, file system, etc.) - -## Example Test - -```python -def test_my_function(mock_connection): - from deploy import my_function - - with patch('deploy.SOME_VAR', 'value'): - result = my_function(mock_connection) - assert result is not None -``` +The integration tests include: +1. **SSH Connection Test**: Verifies basic SSH connectivity +2. **Git Tools Test**: Confirms Git is installed on the remote +3. **Environment File Generation Test**: Tests remote `.env` file creation +4. **Deploy Simulation Test**: Validates deployment command execution -## CI/CD +## Docker Environment -Tests run automatically on: -- Push to main/develop branches -- Pull requests -- Manual trigger via workflow_dispatch +- **Container**: Ubuntu 22.04 with SSH, Git, Python3 +- **Port**: 2222 (mapped to container's port 22) +- **Credentials**: root/root +- **Lifecycle**: Automatically managed by pytest fixtures -See `.github/workflows/test.yml` for CI configuration. +The Docker container is automatically started before tests and stopped after completion. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8604934 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import os + +# Load .env.test from root into environment +env_test_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env.test") +if os.path.exists(env_test_path): + with open(env_test_path, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + value = value.strip() + # Strip surrounding quotes if present + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + # Unescape \n to real newlines for blobs + os.environ[key.strip()] = value.replace("\\n", "\n") diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.api b/tests/generated_envs/auto_dev/.envs/dev/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.app b/tests/generated_envs/auto_dev/.envs/dev/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.database b/tests/generated_envs/auto_dev/.envs/dev/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.elastic b/tests/generated_envs/auto_dev/.envs/dev/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.kafka b/tests/generated_envs/auto_dev/.envs/dev/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.minio b/tests/generated_envs/auto_dev/.envs/dev/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.redis b/tests/generated_envs/auto_dev/.envs/dev/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.s3 b/tests/generated_envs/auto_dev/.envs/dev/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/auto_dev/.envs/dev/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/create_root_agg/.env b/tests/generated_envs/create_root_agg/.env new file mode 100644 index 0000000..8ad434d --- /dev/null +++ b/tests/generated_envs/create_root_agg/.env @@ -0,0 +1,12 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true +DATABASE_DB_URL=postgres://db:5432 +DATABASE_DB_USER=json-admin +REDIS_HOST=redis-yaml-master +REDIS_PORT=6379 +MINIO_ENDPOINT=http://minio:9000 +S3_BUCKET=my-assets +KAFKA_TOPIC=events-main +ELASTIC_URL=http://elastic:9200 +API_KEY=api-key-12345 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.api b/tests/generated_envs/create_root_agg/.envs/dev/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.app b/tests/generated_envs/create_root_agg/.envs/dev/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.database b/tests/generated_envs/create_root_agg/.envs/dev/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.elastic b/tests/generated_envs/create_root_agg/.envs/dev/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.kafka b/tests/generated_envs/create_root_agg/.envs/dev/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.minio b/tests/generated_envs/create_root_agg/.envs/dev/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.redis b/tests/generated_envs/create_root_agg/.envs/dev/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.s3 b/tests/generated_envs/create_root_agg/.envs/dev/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/create_root_agg/.envs/dev/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/custom_path_abs/.env.api b/tests/generated_envs/custom_path_abs/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/custom_path_abs/.env.app b/tests/generated_envs/custom_path_abs/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/generated_envs/custom_path_abs/.env.database b/tests/generated_envs/custom_path_abs/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/generated_envs/custom_path_abs/.env.elastic b/tests/generated_envs/custom_path_abs/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/custom_path_abs/.env.kafka b/tests/generated_envs/custom_path_abs/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/custom_path_abs/.env.minio b/tests/generated_envs/custom_path_abs/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/custom_path_abs/.env.redis b/tests/generated_envs/custom_path_abs/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/generated_envs/custom_path_abs/.env.s3 b/tests/generated_envs/custom_path_abs/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/custom_path_abs/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.api b/tests/generated_envs/custom_path_rel/my_configs/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.app b/tests/generated_envs/custom_path_rel/my_configs/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.database b/tests/generated_envs/custom_path_rel/my_configs/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.elastic b/tests/generated_envs/custom_path_rel/my_configs/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.kafka b/tests/generated_envs/custom_path_rel/my_configs/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.minio b/tests/generated_envs/custom_path_rel/my_configs/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.redis b/tests/generated_envs/custom_path_rel/my_configs/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.s3 b/tests/generated_envs/custom_path_rel/my_configs/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/custom_path_rel/my_configs/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/explicit_patterns/.env.only_app b/tests/generated_envs/explicit_patterns/.env.only_app new file mode 100644 index 0000000..c655ea7 --- /dev/null +++ b/tests/generated_envs/explicit_patterns/.env.only_app @@ -0,0 +1 @@ +VAR=app_val diff --git a/tests/generated_envs/explicit_patterns/.env.only_db b/tests/generated_envs/explicit_patterns/.env.only_db new file mode 100644 index 0000000..1b4446e --- /dev/null +++ b/tests/generated_envs/explicit_patterns/.env.only_db @@ -0,0 +1 @@ +VAR=db_val diff --git a/tests/generated_envs/file_path_secret/.env.api b/tests/generated_envs/file_path_secret/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/file_path_secret/.env.app b/tests/generated_envs/file_path_secret/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/generated_envs/file_path_secret/.env.database b/tests/generated_envs/file_path_secret/.env.database new file mode 100644 index 0000000..b293acf --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.database @@ -0,0 +1,2 @@ +FILE_DB_USER=file-user-admin +FILE_DB_PASS=file-secret-pass diff --git a/tests/generated_envs/file_path_secret/.env.elastic b/tests/generated_envs/file_path_secret/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/file_path_secret/.env.kafka b/tests/generated_envs/file_path_secret/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/file_path_secret/.env.minio b/tests/generated_envs/file_path_secret/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/file_path_secret/.env.redis b/tests/generated_envs/file_path_secret/.env.redis new file mode 100644 index 0000000..afae57f --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.redis @@ -0,0 +1,2 @@ +HOST=yaml-file-host +PORT=6379 diff --git a/tests/generated_envs/file_path_secret/.env.s3 b/tests/generated_envs/file_path_secret/.env.s3 new file mode 100644 index 0000000..7b6f28e --- /dev/null +++ b/tests/generated_envs/file_path_secret/.env.s3 @@ -0,0 +1,2 @@ +BUCKET=file-bucket +REGION=us-east-1 diff --git a/tests/generated_envs/flat_staging/.env.api b/tests/generated_envs/flat_staging/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/flat_staging/.env.app b/tests/generated_envs/flat_staging/.env.app new file mode 100644 index 0000000..fb71b81 --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=3000 +DEBUG=true diff --git a/tests/generated_envs/flat_staging/.env.database b/tests/generated_envs/flat_staging/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/generated_envs/flat_staging/.env.elastic b/tests/generated_envs/flat_staging/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/flat_staging/.env.kafka b/tests/generated_envs/flat_staging/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/flat_staging/.env.minio b/tests/generated_envs/flat_staging/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/flat_staging/.env.redis b/tests/generated_envs/flat_staging/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/generated_envs/flat_staging/.env.s3 b/tests/generated_envs/flat_staging/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/flat_staging/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/formats_parsing/.env.api b/tests/generated_envs/formats_parsing/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/formats_parsing/.env.app b/tests/generated_envs/formats_parsing/.env.app new file mode 100644 index 0000000..1ff5552 --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.app @@ -0,0 +1,4 @@ +BASE_URL=https://app.com +PORT=9000 +DEBUG=true +SECRET=prod-exclusive-secret diff --git a/tests/generated_envs/formats_parsing/.env.database b/tests/generated_envs/formats_parsing/.env.database new file mode 100644 index 0000000..ee8e38d --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=prod-json-admin diff --git a/tests/generated_envs/formats_parsing/.env.elastic b/tests/generated_envs/formats_parsing/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/formats_parsing/.env.kafka b/tests/generated_envs/formats_parsing/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/formats_parsing/.env.minio b/tests/generated_envs/formats_parsing/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/formats_parsing/.env.redis b/tests/generated_envs/formats_parsing/.env.redis new file mode 100644 index 0000000..642dd95 --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-prod-yaml-cluster +PORT=6379 diff --git a/tests/generated_envs/formats_parsing/.env.s3 b/tests/generated_envs/formats_parsing/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/formats_parsing/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.api b/tests/generated_envs/multi_env_nested/.envs/dev/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.app b/tests/generated_envs/multi_env_nested/.envs/dev/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.database b/tests/generated_envs/multi_env_nested/.envs/dev/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic b/tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka b/tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.minio b/tests/generated_envs/multi_env_nested/.envs/dev/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.redis b/tests/generated_envs/multi_env_nested/.envs/dev/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 b/tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.api b/tests/generated_envs/multi_env_nested/.envs/prod/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.app b/tests/generated_envs/multi_env_nested/.envs/prod/.env.app new file mode 100644 index 0000000..1ff5552 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.app @@ -0,0 +1,4 @@ +BASE_URL=https://app.com +PORT=9000 +DEBUG=true +SECRET=prod-exclusive-secret diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.database b/tests/generated_envs/multi_env_nested/.envs/prod/.env.database new file mode 100644 index 0000000..ee8e38d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=prod-json-admin diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic b/tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka b/tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.minio b/tests/generated_envs/multi_env_nested/.envs/prod/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.redis b/tests/generated_envs/multi_env_nested/.envs/prod/.env.redis new file mode 100644 index 0000000..642dd95 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-prod-yaml-cluster +PORT=6379 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 b/tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.api b/tests/generated_envs/multi_env_nested/.envs/staging/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.app b/tests/generated_envs/multi_env_nested/.envs/staging/.env.app new file mode 100644 index 0000000..fb71b81 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=3000 +DEBUG=true diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.database b/tests/generated_envs/multi_env_nested/.envs/staging/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic b/tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka b/tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.minio b/tests/generated_envs/multi_env_nested/.envs/staging/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.redis b/tests/generated_envs/multi_env_nested/.envs/staging/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 b/tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/generated_envs/single_prod/.env b/tests/generated_envs/single_prod/.env new file mode 100644 index 0000000..d7ee7c3 --- /dev/null +++ b/tests/generated_envs/single_prod/.env @@ -0,0 +1,13 @@ +APP_BASE_URL=https://app.com +APP_PORT=9000 +APP_DEBUG=true +DATABASE_DB_URL=postgres://db:5432 +DATABASE_DB_USER=prod-json-admin +REDIS_HOST=redis-prod-yaml-cluster +REDIS_PORT=6379 +MINIO_ENDPOINT=http://minio:9000 +S3_BUCKET=my-assets +KAFKA_TOPIC=events-main +ELASTIC_URL=http://elastic:9200 +API_KEY=api-key-12345 +APP_SECRET=prod-exclusive-secret diff --git a/tests/integration/Dockerfile.test-ssh b/tests/integration/Dockerfile.test-ssh new file mode 100644 index 0000000..8cec0c9 --- /dev/null +++ b/tests/integration/Dockerfile.test-ssh @@ -0,0 +1,29 @@ +FROM ubuntu:22.04 + +# Avoid interactive prompts +ENV DEBIAN_FRONTEND=noninteractive + +# Install SSH, Git, Python3, Sudo +RUN apt-get update && apt-get install -y \ + openssh-server \ + git \ + python3 \ + python3-pip \ + sudo \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Configure SSH +RUN mkdir /var/run/sshd +# Set root password to 'root' +RUN echo 'root:root' | chpasswd +# Permit root login +RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config +# Fix PAM login issues +RUN sed -i 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' /etc/pam.d/sshd + +# Expose SSH port +EXPOSE 22 + +# Generate keys and run sshd +CMD ["bash", "-c", "ssh-keygen -A; /usr/sbin/sshd -D -e"] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..54c19d6 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,113 @@ +import os +import subprocess +import time + +import pytest +from fabric import Connection + + +@pytest.fixture(scope="session") +def ssh_container(): + """Spin up the Docker container with SSH server.""" + import socket + + docker_compose_file = os.path.join(os.path.dirname(__file__), "docker-compose.yml") + tests_dir = os.path.dirname(__file__) + + # Start container with output capture + print("🐳 Starting SSH container...") + result = subprocess.run( + ["docker", "compose", "-f", docker_compose_file, "up", "-d"], + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + cwd=tests_dir, + ) + + if result.returncode != 0: + print(f"❌ Docker compose failed: {result.stderr}") + pytest.fail(f"Failed to start Docker container: {result.stderr}") + + # Give container time to fully start sshd + time.sleep(5) + + # Connection parameters + host = "127.0.0.1" + port = 2222 + user = "root" + password = "root" + + # Wait for SSH to be ready with socket check first + retries = 30 + ready = False + + for i in range(retries): + try: + # Check if port is listening + with socket.create_connection((host, port), timeout=2): + pass + + # Verify SSH actually works + conn = Connection( + host=host, + port=port, + user=user, + connect_kwargs={ + "password": password, + "look_for_keys": False, + "allow_agent": False, + }, + ) + conn.run("echo 'SSH Ready'", hide=True) + conn.close() + ready = True + print("✅ SSH Container Ready") + break + except Exception as e: + if i < retries - 1: + time.sleep(1) + else: + print(f"Final connection attempt failed: {e}") + + if not ready: + # Capture logs before failing + logs = subprocess.run( + ["docker", "logs", "tests-ssh-server-1"], + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + ) + print(f"Container logs:\n{logs.stdout}") + subprocess.run( + ["docker", "compose", "-f", docker_compose_file, "down"], + check=False, + stdin=subprocess.DEVNULL, + cwd=tests_dir, + ) + pytest.fail("Could not connect to SSH container after 30 retries") + + yield {"host": host, "port": port, "user": user, "password": password} + + # Teardown + print("🛑 Stopping SSH container...") + subprocess.run( + ["docker", "compose", "-f", docker_compose_file, "down"], + check=False, + stdin=subprocess.DEVNULL, + cwd=tests_dir, + ) + + +@pytest.fixture +def integration_conn(ssh_container): + """Provide a fabric connection to the container.""" + return Connection( + host=ssh_container["host"], + port=ssh_container["port"], + user=ssh_container["user"], + connect_kwargs={ + "password": ssh_container["password"], + "look_for_keys": False, + "allow_agent": False, + }, + ) diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 0000000..116a022 --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,11 @@ +services: + ssh-server: + build: + context: . + dockerfile: Dockerfile.test-ssh + ports: + - "2222:22" + volumes: + - ../:/opt/metaldeploy_source + - ./generated_envs:/opt/metaldeploy_tests + restart: always diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.api b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.app b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.database b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.elastic b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.kafka b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.minio b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.s3 b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/create_root_agg/.env b/tests/integration/generated_envs/create_root_agg/.env new file mode 100644 index 0000000..8ad434d --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.env @@ -0,0 +1,12 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true +DATABASE_DB_URL=postgres://db:5432 +DATABASE_DB_USER=json-admin +REDIS_HOST=redis-yaml-master +REDIS_PORT=6379 +MINIO_ENDPOINT=http://minio:9000 +S3_BUCKET=my-assets +KAFKA_TOPIC=events-main +ELASTIC_URL=http://elastic:9200 +API_KEY=api-key-12345 diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.api b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.app b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.database b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.elastic b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.kafka b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.minio b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.redis b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.s3 b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/custom_path_abs/.env.api b/tests/integration/generated_envs/custom_path_abs/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/custom_path_abs/.env.app b/tests/integration/generated_envs/custom_path_abs/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/integration/generated_envs/custom_path_abs/.env.database b/tests/integration/generated_envs/custom_path_abs/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/integration/generated_envs/custom_path_abs/.env.elastic b/tests/integration/generated_envs/custom_path_abs/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/custom_path_abs/.env.kafka b/tests/integration/generated_envs/custom_path_abs/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/custom_path_abs/.env.minio b/tests/integration/generated_envs/custom_path_abs/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/custom_path_abs/.env.redis b/tests/integration/generated_envs/custom_path_abs/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/integration/generated_envs/custom_path_abs/.env.s3 b/tests/integration/generated_envs/custom_path_abs/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/custom_path_abs/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.api b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.app b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.database b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.elastic b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.kafka b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.minio b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.redis b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.s3 b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/explicit_patterns/.env.only_app b/tests/integration/generated_envs/explicit_patterns/.env.only_app new file mode 100644 index 0000000..c655ea7 --- /dev/null +++ b/tests/integration/generated_envs/explicit_patterns/.env.only_app @@ -0,0 +1 @@ +VAR=app_val diff --git a/tests/integration/generated_envs/explicit_patterns/.env.only_db b/tests/integration/generated_envs/explicit_patterns/.env.only_db new file mode 100644 index 0000000..1b4446e --- /dev/null +++ b/tests/integration/generated_envs/explicit_patterns/.env.only_db @@ -0,0 +1 @@ +VAR=db_val diff --git a/tests/integration/generated_envs/file_path_secret/.env.api b/tests/integration/generated_envs/file_path_secret/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/file_path_secret/.env.app b/tests/integration/generated_envs/file_path_secret/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/integration/generated_envs/file_path_secret/.env.database b/tests/integration/generated_envs/file_path_secret/.env.database new file mode 100644 index 0000000..b293acf --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.database @@ -0,0 +1,2 @@ +FILE_DB_USER=file-user-admin +FILE_DB_PASS=file-secret-pass diff --git a/tests/integration/generated_envs/file_path_secret/.env.elastic b/tests/integration/generated_envs/file_path_secret/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/file_path_secret/.env.kafka b/tests/integration/generated_envs/file_path_secret/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/file_path_secret/.env.minio b/tests/integration/generated_envs/file_path_secret/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/file_path_secret/.env.redis b/tests/integration/generated_envs/file_path_secret/.env.redis new file mode 100644 index 0000000..afae57f --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.redis @@ -0,0 +1,2 @@ +HOST=yaml-file-host +PORT=6379 diff --git a/tests/integration/generated_envs/file_path_secret/.env.s3 b/tests/integration/generated_envs/file_path_secret/.env.s3 new file mode 100644 index 0000000..7b6f28e --- /dev/null +++ b/tests/integration/generated_envs/file_path_secret/.env.s3 @@ -0,0 +1,2 @@ +BUCKET=file-bucket +REGION=us-east-1 diff --git a/tests/integration/generated_envs/flat_staging/.env.api b/tests/integration/generated_envs/flat_staging/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/flat_staging/.env.app b/tests/integration/generated_envs/flat_staging/.env.app new file mode 100644 index 0000000..fb71b81 --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=3000 +DEBUG=true diff --git a/tests/integration/generated_envs/flat_staging/.env.database b/tests/integration/generated_envs/flat_staging/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/integration/generated_envs/flat_staging/.env.elastic b/tests/integration/generated_envs/flat_staging/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/flat_staging/.env.kafka b/tests/integration/generated_envs/flat_staging/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/flat_staging/.env.minio b/tests/integration/generated_envs/flat_staging/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/flat_staging/.env.redis b/tests/integration/generated_envs/flat_staging/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/integration/generated_envs/flat_staging/.env.s3 b/tests/integration/generated_envs/flat_staging/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/flat_staging/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/formats_parsing/.env.api b/tests/integration/generated_envs/formats_parsing/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/formats_parsing/.env.app b/tests/integration/generated_envs/formats_parsing/.env.app new file mode 100644 index 0000000..1ff5552 --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.app @@ -0,0 +1,4 @@ +BASE_URL=https://app.com +PORT=9000 +DEBUG=true +SECRET=prod-exclusive-secret diff --git a/tests/integration/generated_envs/formats_parsing/.env.database b/tests/integration/generated_envs/formats_parsing/.env.database new file mode 100644 index 0000000..ee8e38d --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=prod-json-admin diff --git a/tests/integration/generated_envs/formats_parsing/.env.elastic b/tests/integration/generated_envs/formats_parsing/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/formats_parsing/.env.kafka b/tests/integration/generated_envs/formats_parsing/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/formats_parsing/.env.minio b/tests/integration/generated_envs/formats_parsing/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/formats_parsing/.env.redis b/tests/integration/generated_envs/formats_parsing/.env.redis new file mode 100644 index 0000000..642dd95 --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-prod-yaml-cluster +PORT=6379 diff --git a/tests/integration/generated_envs/formats_parsing/.env.s3 b/tests/integration/generated_envs/formats_parsing/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/formats_parsing/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.api b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.app b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.app new file mode 100644 index 0000000..f853147 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=8000 +DEBUG=true diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.database b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.elastic b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.kafka b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.minio b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.redis b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.s3 b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.api b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.app b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.app new file mode 100644 index 0000000..1ff5552 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.app @@ -0,0 +1,4 @@ +BASE_URL=https://app.com +PORT=9000 +DEBUG=true +SECRET=prod-exclusive-secret diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.database b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.database new file mode 100644 index 0000000..ee8e38d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=prod-json-admin diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.elastic b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.kafka b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.minio b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.redis b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.redis new file mode 100644 index 0000000..642dd95 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-prod-yaml-cluster +PORT=6379 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.s3 b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.api b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.api new file mode 100644 index 0000000..2836fc9 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.api @@ -0,0 +1 @@ +KEY=api-key-12345 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.app b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.app new file mode 100644 index 0000000..fb71b81 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.app @@ -0,0 +1,3 @@ +BASE_URL=https://app.com +PORT=3000 +DEBUG=true diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.database b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.database new file mode 100644 index 0000000..66353c8 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.database @@ -0,0 +1,2 @@ +DB_URL=postgres://db:5432 +DB_USER=json-admin diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.elastic b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.elastic new file mode 100644 index 0000000..be87a23 --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.elastic @@ -0,0 +1 @@ +URL=http://elastic:9200 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.kafka b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.kafka new file mode 100644 index 0000000..f76b57d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.kafka @@ -0,0 +1 @@ +TOPIC=events-main diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.minio b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.minio new file mode 100644 index 0000000..955652d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.minio @@ -0,0 +1 @@ +ENDPOINT=http://minio:9000 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.redis b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.redis new file mode 100644 index 0000000..da5493d --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.redis @@ -0,0 +1,2 @@ +HOST=redis-yaml-master +PORT=6379 diff --git a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.s3 b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.s3 new file mode 100644 index 0000000..f971efd --- /dev/null +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.s3 @@ -0,0 +1 @@ +BUCKET=my-assets diff --git a/tests/integration/generated_envs/single_prod/.env b/tests/integration/generated_envs/single_prod/.env new file mode 100644 index 0000000..d7ee7c3 --- /dev/null +++ b/tests/integration/generated_envs/single_prod/.env @@ -0,0 +1,13 @@ +APP_BASE_URL=https://app.com +APP_PORT=9000 +APP_DEBUG=true +DATABASE_DB_URL=postgres://db:5432 +DATABASE_DB_USER=prod-json-admin +REDIS_HOST=redis-prod-yaml-cluster +REDIS_PORT=6379 +MINIO_ENDPOINT=http://minio:9000 +S3_BUCKET=my-assets +KAFKA_TOPIC=events-main +ELASTIC_URL=http://elastic:9200 +API_KEY=api-key-12345 +APP_SECRET=prod-exclusive-secret diff --git a/tests/integration/test_integration_full.py b/tests/integration/test_integration_full.py new file mode 100644 index 0000000..585b3eb --- /dev/null +++ b/tests/integration/test_integration_full.py @@ -0,0 +1,293 @@ +import pytest + +from src.config import config +from src.env_manager import generate_env_files + + +@pytest.fixture +def clean_remote_dir(integration_conn): + """Fixture to provide clean directories for each test.""" + base_dir = "/opt/metaldeploy_tests" + integration_conn.run(f"mkdir -p {base_dir}") + + def _clean(subdir): + target = f"{base_dir}/{subdir}" + integration_conn.run(f"rm -rf {target} && mkdir -p {target} && chmod 777 {target}") + return target + + return _clean + + +def assert_file_content(conn, path, expected_substrings, forbidden_substrings=None): + """Helper to assert file existence and content.""" + assert conn.run(f"test -f {path}", warn=True).ok, f"File not found: {path}" + content = conn.run(f"cat {path}", hide=True).stdout + for sub in expected_substrings: + assert sub in content, f"Substring '{sub}' not found in {path}. Content:\n{content}" + if forbidden_substrings: + for sub in forbidden_substrings: + assert ( + sub not in content + ), f"Forbidden substring '{sub}' found in {path}. Content:\n{content}" + + +@pytest.mark.integration +def test_ssh_connection(integration_conn): + """Test basic SSH connectivity.""" + result = integration_conn.run("whoami", hide=True) + assert result.stdout.strip() == "root" + + +# ------------------------------------------------------------------------------ +# HYPER-EXHAUSTIVE PERMUTATION TESTS +# ------------------------------------------------------------------------------ + + +@pytest.mark.integration +def test_env_exhaustive_multi_env_nested(integration_conn, clean_remote_dir, monkeypatch): + """ + 1. Structure: NESTED + 2. Environments: DEV, STAGING, PROD (Coexistence) + 3. Formats: Standard .env blobs + """ + target_dir = clean_remote_dir("multi_env_nested") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "nested") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + for env in ["dev", "staging", "prod"]: + monkeypatch.setattr(config, "ENVIRONMENT", env) + generate_env_files(integration_conn) + + # Assert dev structure + assert_file_content(integration_conn, f"{target_dir}/.envs/dev/.env.app", ["PORT=8000"]) + # Assert staging structure + assert_file_content(integration_conn, f"{target_dir}/.envs/staging/.env.app", ["PORT=3000"]) + # Assert prod structure + assert_file_content( + integration_conn, + f"{target_dir}/.envs/prod/.env.app", + ["PORT=9000", "SECRET=prod-exclusive-secret"], + ) + + +@pytest.mark.integration +def test_env_exhaustive_flat_staging(integration_conn, clean_remote_dir, monkeypatch): + """ + 1. Structure: FLAT + 2. Environment: STAGING + 3. Behavior: Files at root + """ + target_dir = clean_remote_dir("flat_staging") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "flat") + monkeypatch.setattr(config, "ENVIRONMENT", "staging") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + generate_env_files(integration_conn) + + assert_file_content(integration_conn, f"{target_dir}/.env.app", ["PORT=3000"]) + assert_file_content( + integration_conn, f"{target_dir}/.env.database", ["DB_URL=postgres://db:5432"] + ) + + +@pytest.mark.integration +def test_env_exhaustive_single_prod(integration_conn, clean_remote_dir, monkeypatch): + """ + 1. Structure: SINGLE + 2. Environment: PROD + 3. Behavior: All in one .env file + """ + target_dir = clean_remote_dir("single_prod") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "single") + monkeypatch.setattr(config, "ENVIRONMENT", "prod") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + generate_env_files(integration_conn) + + assert_file_content( + integration_conn, + f"{target_dir}/.env", + ["APP_PORT=9000", "DATABASE_DB_USER=prod-json-admin", "REDIS_HOST=redis-prod-yaml-cluster"], + forbidden_substrings=["FILES_GENERATE", "FILES_STRUCTURE", "FILES_FORMAT"], + ) + + +@pytest.mark.integration +def test_env_exhaustive_auto_dev(integration_conn, clean_remote_dir, monkeypatch): + """ + 1. Structure: AUTO (should behave like nested if multiple folders exist) + 2. Environment: DEV + """ + target_dir = clean_remote_dir("auto_dev") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "auto") + monkeypatch.setattr(config, "ENVIRONMENT", "dev") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + generate_env_files(integration_conn) + + # Auto on empty dir defaults to nested-like logic + assert_file_content(integration_conn, f"{target_dir}/.envs/dev/.env.app", ["PORT=8000"]) + + +@pytest.mark.integration +def test_env_exhaustive_custom_path_relative(integration_conn, clean_remote_dir, monkeypatch): + """ + 1. Path: Custom Relative (my_configs) + 2. Structure: FLAT + """ + target_dir = clean_remote_dir("custom_path_rel") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "flat") + monkeypatch.setattr(config, "ENV_FILES_PATH", "my_configs") + monkeypatch.setattr(config, "ENVIRONMENT", "dev") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + generate_env_files(integration_conn) + + assert_file_content(integration_conn, f"{target_dir}/my_configs/.env.app", ["PORT=8000"]) + + +@pytest.mark.integration +def test_env_exhaustive_custom_path_absolute(integration_conn, clean_remote_dir, monkeypatch): + """ + 1. Path: Custom Absolute (/tmp/metaldeploy_abs) + """ + clean_remote_dir("custom_path_abs") + abs_path = "/opt/metaldeploy_tests/custom_path_abs" + # No need to recreate abs_path since clean_remote_dir already did it + + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "flat") + monkeypatch.setattr(config, "ENV_FILES_PATH", abs_path) + monkeypatch.setattr(config, "ENVIRONMENT", "dev") + monkeypatch.setattr(config, "GIT_SUBDIR", "/tmp/random_cwd_for_abs_test") + integration_conn.run("mkdir -p /tmp/random_cwd_for_abs_test") + + generate_env_files(integration_conn) + + assert_file_content(integration_conn, f"{abs_path}/.env.app", ["PORT=8000"]) + + +@pytest.mark.integration +def test_env_exhaustive_json_yaml_formats(integration_conn, clean_remote_dir, monkeypatch): + """ + Verify JSON and YAML parsing correctness in output. + """ + target_dir = clean_remote_dir("formats_parsing") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "flat") + monkeypatch.setattr(config, "ENVIRONMENT", "prod") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + generate_env_files(integration_conn) + + # DATABASE was JSON in .env.test + assert_file_content( + integration_conn, f"{target_dir}/.env.database", ["DB_USER=prod-json-admin"] + ) + # REDIS was YAML in .env.test + assert_file_content( + integration_conn, f"{target_dir}/.env.redis", ["HOST=redis-prod-yaml-cluster"] + ) + + +@pytest.mark.integration +def test_env_exhaustive_create_root_aggregated(integration_conn, clean_remote_dir, monkeypatch): + """ + Flag: ENV_FILES_CREATE_ROOT=true + """ + target_dir = clean_remote_dir("create_root_agg") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "nested") + monkeypatch.setattr(config, "ENV_FILES_CREATE_ROOT", True) + monkeypatch.setattr(config, "ENVIRONMENT", "dev") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + generate_env_files(integration_conn) + + # Both nested and root combined file should exist + assert_file_content(integration_conn, f"{target_dir}/.envs/dev/.env.app", ["PORT=8000"]) + assert_file_content( + integration_conn, + f"{target_dir}/.env", + ["APP_PORT=8000", "DATABASE_DB_USER=json-admin"], + forbidden_substrings=["FILES_GENERATE", "FILES_STRUCTURE", "FILES_FORMAT"], + ) + + +@pytest.mark.integration +def test_env_exhaustive_explicit_patterns(integration_conn, clean_remote_dir, monkeypatch): + """ + Flag: ENV_FILES_PATTERNS (Explicit list) + """ + target_dir = clean_remote_dir("explicit_patterns") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "flat") + monkeypatch.setattr(config, "ENV_FILES_PATTERNS", [".env.only_app", ".env.only_db"]) + monkeypatch.setattr(config, "ENVIRONMENT", "dev") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + # We need variables matching ONLY_APP and ONLY_DB + monkeypatch.setenv("ENV_ONLY_APP_VAR", "app_val") + monkeypatch.setenv("ENV_ONLY_DB_VAR", "db_val") + + generate_env_files(integration_conn) + + assert_file_content(integration_conn, f"{target_dir}/.env.only_app", ["VAR=app_val"]) + assert_file_content(integration_conn, f"{target_dir}/.env.only_db", ["VAR=db_val"]) + + +@pytest.mark.integration +def test_env_exhaustive_file_path_secret(integration_conn, clean_remote_dir, monkeypatch, tmp_path): + """ + Verify reading secrets from a file path (Jenkins/CI style). + """ + target_dir = clean_remote_dir("file_path_secret") + + # Create a local secret file + secret_file = tmp_path / "jenkins_secret.json" + secret_content = '{"FILE_DB_USER": "file-user-admin", "FILE_DB_PASS": "file-secret-pass"}' + secret_file.write_text(secret_content) + + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "ENV_FILES_STRUCTURE", "flat") + monkeypatch.setattr(config, "ENVIRONMENT", "dev") + monkeypatch.setattr(config, "GIT_SUBDIR", target_dir) + + # Point the environment variable to the local file path + monkeypatch.setenv("ENV_DATABASE", str(secret_file)) + + generate_env_files(integration_conn) + + # Verify JSON from file + assert_file_content( + integration_conn, + f"{target_dir}/.env.database", + ["DB_USER=file-user-admin", "DB_PASS=file-secret-pass"], + ) + + # 2. Test YAML from file + yaml_file = tmp_path / "jenkins_secret.yaml" + yaml_file.write_text("HOST: yaml-file-host\nPORT: 6379") + monkeypatch.setenv("ENV_REDIS", str(yaml_file)) + + # 3. Test standard .env from file + env_file = tmp_path / "jenkins_secret.env" + env_file.write_text("S3_BUCKET=file-bucket\nS3_REGION=us-east-1") + monkeypatch.setenv("ENV_S3", str(env_file)) + + generate_env_files(integration_conn) + + # Verify YAML from file + assert_file_content( + integration_conn, f"{target_dir}/.env.redis", ["HOST=yaml-file-host", "PORT=6379"] + ) + + # Verify .env from file + assert_file_content( + integration_conn, f"{target_dir}/.env.s3", ["BUCKET=file-bucket", "REGION=us-east-1"] + ) From c1420beb081475afca9c5a3421f57c2954a6bba5 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:29:11 +0100 Subject: [PATCH 04/13] config(config): update configuration files for better code quality --- .env.test | 48 ++++ .flake8 | 4 + .pre-commit-config.yaml | 2 +- Makefile | 17 +- changelogs/2026-01-29_14-25-29.md | 26 ++ pytest.ini | 2 +- src/config.py | 5 +- src/env_manager.py | 385 ++++++++++++++++++++++-------- 8 files changed, 376 insertions(+), 113 deletions(-) create mode 100644 .env.test create mode 100644 .flake8 create mode 100644 changelogs/2026-01-29_14-25-29.md diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..6e5597b --- /dev/null +++ b/.env.test @@ -0,0 +1,48 @@ +# ============================================================================== +# METADEPLOY HYPER-EXHAUSTIVE TEST CONFIGURATION +# ============================================================================== + +# --- [GIT & AUTH] --- +GIT_URL=https://github.com/OpsGuild/MetalDeploy.git +GIT_USER=hordunlarmy +GIT_AUTH_METHOD=none + +# --- [REMOTE TARGETS] --- +ENVIRONMENT=dev +REMOTE_USER=root +REMOTE_HOST=localhost +REMOTE_PORT=2222 +REMOTE_DIR=/opt/metaldeploy + +# --- [ENVIRONMENT FILE GENERATION] --- +ENV_FILES_GENERATE=true +ENV_FILES_STRUCTURE=auto +ENV_FILES_FORMAT=auto + +# ============================================================================== +# EXHAUSTIVE TEST SECRETS (Multi-Format & Components) +# ============================================================================== + +# --- [STANDARD ENV BLOBS] --- +ENV_APP=APP_BASE_URL=https://app.com\nAPP_PORT=8000\nAPP_DEBUG=true +ENV_PROD_APP=APP_PORT=9000\nAPP_SECRET=prod-exclusive-secret + +# --- [JSON BLOBS] --- +ENV_DATABASE={"DB_URL": "postgres://db:5432", "DB_USER": "json-admin"} +ENV_PROD_DATABASE={"DB_USER": "prod-json-admin"} + +# --- [YAML BLOBS] --- +ENV_REDIS="REDIS_HOST: redis-yaml-master\nREDIS_PORT: 6379" +ENV_PROD_REDIS="REDIS_HOST: redis-prod-yaml-cluster\nREDIS_PORT: 6379" + +# --- [INDIVIDUAL OVERRIDES] --- +ENV_MINIO_ENDPOINT=http://minio:9000 +ENV_S3_BUCKET=my-assets +ENV_KAFKA_TOPIC=events-main +ENV_ELASTIC_URL=http://elastic:9200 +ENV_API_KEY=api-key-12345 + +# --- [MULTI-ENV OVERRIDES] --- +ENV_STAGING_APP_PORT=3000 +ENV_DEV_APP_PORT=8000 +ENV_PROD_APP_PORT=9000 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4d3122c --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 100 +ignore = E501, W503, E203 +exclude = .git, __pycache__, *.pyc, .pytest_cache, .venv, venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5253e59..6daaf9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,5 +29,5 @@ repos: - id: flake8 args: - --max-line-length=100 - - --ignore=E501,W503 + - --ignore=E501,W503,E203 additional_dependencies: [flake8] diff --git a/Makefile b/Makefile index 48d9656..ef89f91 100644 --- a/Makefile +++ b/Makefile @@ -6,20 +6,24 @@ install-dev: # Run all tests test: - poetry run pytest tests/ -v + poetry run pytest tests/ -v -s --cov=src --cov=main --cov-report=xml # Run only unit tests (fast) test-unit: - poetry run pytest tests/ -v -m "not integration and not slow" + poetry run pytest tests/unit/ -v + +# Run integration tests (requires Docker) +test-integration: + poetry run pytest tests/integration/ -v -s # Run tests with coverage test-coverage: - poetry run pytest tests/ --cov=deploy --cov-report=html --cov-report=term -m "not integration and not slow" + poetry run pytest tests/unit/ --cov=src --cov=main --cov-report=html --cov-report=term @echo "Coverage report generated in htmlcov/index.html" # Run linting lint: - poetry run flake8 . --max-line-length=100 --ignore=E501,W503 --exclude=.git,__pycache__,*.pyc,.pytest_cache,.venv,venv + poetry run flake8 . poetry run black --check . poetry run isort --check-only . @@ -40,4 +44,7 @@ clean: # Validate action.yml validate: poetry run python -c "import yaml; yaml.safe_load(open('action.yml'))" - @echo "✅ action.yml is valid YAML" + @grep -q "name:" action.yml || (echo "❌ Missing 'name' in action.yml" && exit 1) + @grep -q "description:" action.yml || (echo "❌ Missing 'description' in action.yml" && exit 1) + @grep -q "runs:" action.yml || (echo "❌ Missing 'runs' in action.yml" && exit 1) + @echo "✅ action.yml is valid and structurally correct" diff --git a/changelogs/2026-01-29_14-25-29.md b/changelogs/2026-01-29_14-25-29.md new file mode 100644 index 0000000..879313d --- /dev/null +++ b/changelogs/2026-01-29_14-25-29.md @@ -0,0 +1,26 @@ +# Changelog + +## [Unreleased] + +### Removed +- Removed example workflow files for baremetal, docker, and k8s deployments. +- Removed several test files, including `test_connection.py`, `test_db_utils.py`, `test_env_manager.py`, `test_errors.py`, `test_git_ops.py`, `test_integration.py`, `test_orchestrator.py`, and `test_providers.py`. +- Removed unused code and functionality. + +### Changed +- Updated test suite to use `make test` for running all tests and `make test-unit` for running unit tests. +- Changed the `lint` target in the Makefile to run `flake8`, `black`, and `isort` checks. +- Updated the `test` target in the Makefile to run `pytest` with coverage reporting. +- Modified the `parse_all_in_one_secret` function to handle empty strings and file paths. +- Improved the `detect_file_patterns` function to handle environment-specific variables and custom patterns. +- Updated the `determine_file_structure` function to handle the "single" structure and custom paths. +- Changed the `merge_env_vars_by_priority` function to handle global and environment-specific variables. +- Modified the `generate_env_files` function to handle errors and exceptions. +- Updated the `create_env_file` function to set permissions to 644 instead of 600. + +### Added +- Added a `test-integration` target to the Makefile to run integration tests. +- Added a `validate` target to the Makefile to validate the `action.yml` file. +- Added environment variable `ENV_FILES_PATTERNS` to allow custom patterns for environment files. +- Added support for nested environment file structures. +- Added error handling for SSH connections and Git operations. \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index 8260f2a..ff36de0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,5 +9,5 @@ addopts = --strict-markers markers = unit: Unit tests that don't require external dependencies - integration: Integration tests that require external services + integration: Integration tests that require external services (run with -s) slow: Tests that take a long time to run diff --git a/src/config.py b/src/config.py index cd292b5..c4a43b3 100644 --- a/src/config.py +++ b/src/config.py @@ -62,9 +62,8 @@ def get_env(name, default=None): self.ENV_FILES_GENERATE = get_bool_env("ENV_FILES_GENERATE") self.ENV_FILES_STRUCTURE = os.getenv("ENV_FILES_STRUCTURE", "auto").lower() self.ENV_FILES_PATH = os.getenv("ENV_FILES_PATH") - self.ENV_FILES_PATTERNS = os.getenv("ENV_FILES_PATTERNS", ".env.app,.env.database").split( - "," - ) + env_patterns = os.getenv("ENV_FILES_PATTERNS") + self.ENV_FILES_PATTERNS = env_patterns.split(",") if env_patterns else [] self.ENV_FILES_CREATE_ROOT = get_bool_env("ENV_FILES_CREATE_ROOT", "false") self.ENV_FILES_FORMAT = os.getenv("ENV_FILES_FORMAT", "auto").lower() diff --git a/src/env_manager.py b/src/env_manager.py index 1924352..19c576f 100644 --- a/src/env_manager.py +++ b/src/env_manager.py @@ -10,72 +10,131 @@ def parse_all_in_one_secret(secret_content: str, format_hint: str = "auto") -> Dict[str, str]: """Parse all-in-one secret with multiple format support""" + if not secret_content: + return {} + + secret_content = secret_content.strip() + + # Check if the content is a file path (common in Jenkins/CI) + if os.path.isfile(secret_content): + try: + with open(secret_content, "r") as f: + secret_content = f.read().strip() + except Exception: + pass # Fallback to treating as literal string + + # Pre-process content to handle escaped newlines for JSON/YAML + processed_content = secret_content.replace("\\n", "\n") + if format_hint == "auto": - secret_content = secret_content.strip() - # If content is a file path that exists, read it - if os.path.isfile(secret_content): + # Check if it's a JSON blob + if processed_content.startswith("{") and processed_content.endswith("}"): try: - with open(secret_content, "r") as f: - secret_content = f.read().strip() + parsed = json.loads(processed_content) + if isinstance(parsed, dict): + return {str(k): str(v) for k, v in parsed.items()} except Exception: - pass # Treat as normal string if read fails - - content = secret_content - if content.startswith("{") and content.endswith("}"): - format_hint = "json" - elif ( - content.startswith(("key:", "value:", "-", " {")) or ":" in content - ) and "\n" in content: - format_hint = "yaml" - elif "=" in content and "\n" in content: + pass + + # Check if it looks like YAML (contains keys with colons) + if ":" in processed_content: + try: + parsed = yaml.safe_load(processed_content) + # Only return if it's a dict with more than one item or looks like a mapping + if isinstance(parsed, dict) and ( + len(parsed) > 1 or any(":" in line for line in processed_content.splitlines()) + ): + return {str(k): str(v) for k, v in parsed.items()} + except Exception: + pass + + # Default to ENV if it has equals sign + if "=" in secret_content: format_hint = "env" - try: - if format_hint == "json": - parsed = json.loads(secret_content) - return {str(k): str(v) for k, v in parsed.items()} - elif format_hint == "yaml": - parsed = yaml.safe_load(secret_content) or {} - return {str(k): str(v) for k, v in parsed.items()} - elif format_hint == "env": - env_vars = {} - for line in secret_content.strip().split("\n"): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, value = line.split("=", 1) - env_vars[key.strip()] = value.strip() - return env_vars - except Exception: + if format_hint == "json": + try: + parsed = json.loads(processed_content) + if isinstance(parsed, dict): + return {str(k): str(v) for k, v in parsed.items()} + except Exception: + pass + + if format_hint == "yaml": + try: + parsed = yaml.safe_load(processed_content) + if isinstance(parsed, dict): + return {str(k): str(v) for k, v in parsed.items()} + except Exception: + pass + + if format_hint == "env": env_vars = {} - for line in secret_content.strip().split("\n"): + # Handle both physical newlines and literal \n escapes + lines = re.split(r"\n|\\n", secret_content) + for line in lines: line = line.strip() if line and not line.startswith("#") and "=" in line: - key, value = line.split("=", 1) - env_vars[key.strip()] = value.strip() + try: + key, value = line.split("=", 1) + env_vars[key.strip()] = value.strip() + except ValueError: + continue return env_vars + return {} -def detect_file_patterns(all_env_vars: Dict[str, str], structure: str) -> List[str]: +def detect_file_patterns( + all_env_vars: Dict[str, str], structure: str, environment: str = "" +) -> List[str]: """Auto-detect file patterns from variable names""" if structure == "single": return [".env"] patterns = set() + env_upper = (environment or "").upper() + for var_name in all_env_vars.keys(): - # Exclude config variables if var_name.startswith("ENV_FILES_"): continue - match = re.match(r"^ENV_[A-Z0-9_]*_([A-Z]+)_", var_name) - if match: - filename = match.group(1).lower() - patterns.add(f".env.{filename}") + # Determine if it's environment-specific + matched_env = "" + for env in ["PROD", "STAGING", "DEV", "TEST", "PRODUCTION"]: + if var_name.startswith(f"ENV_{env}_"): + matched_env = env + break + # Handle exact match like ENV_PROD_APP (blob) + if var_name == f"ENV_{env}": + matched_env = env + break + + # If it's for another environment, skip it + if matched_env and matched_env != env_upper: continue - match = re.match(r"^ENV_([A-Z]+)_", var_name) - if match: - filename = match.group(1).lower() - patterns.add(f".env.{filename}") + + # Extract component name + suffix = "" + if matched_env: + # Strip ENV_{ENV}_ or ENV_{ENV} + for prefix in [f"ENV_{matched_env}_", f"ENV_{matched_env}"]: + if var_name.startswith(prefix): + suffix = var_name[len(prefix) :] + break + else: + # Strip ENV_ + suffix = var_name[4:] + + # Clean up leading underscores if any (e.g. _REDIS) + suffix = suffix.lstrip("_") + + if suffix: + filename = suffix.split("_")[0].lower() + if filename: + patterns.add(f".env.{filename}") + else: + pass return sorted(list(patterns)) or [".env.app"] @@ -85,30 +144,45 @@ def determine_file_structure( ) -> Dict[str, str]: """Determine file paths based on structure preference""" file_paths = {} + if structure == "auto": - structure = "flat" if len(patterns) == 1 else "nested" + # Heuristic: multiple patterns or presence of env-specific vars -> nested + has_env_specific = False + if environment: + env_upper = environment.upper() + for var_name in os.environ: + if var_name.startswith(f"ENV_{env_upper}_"): + has_env_specific = True + break + + if len(patterns) > 1 or has_env_specific: + structure = "nested" + else: + structure = "flat" - custom_base = base_path - if config.ENV_FILES_PATH and structure in ["auto", "nested"]: - custom_base = config.ENV_FILES_PATH + dir_base = base_path + use_default_envs = True + if config.ENV_FILES_PATH: + # Join relative path with base_path, keep absolute path as is + if os.path.isabs(config.ENV_FILES_PATH): + dir_base = config.ENV_FILES_PATH + else: + dir_base = os.path.join(base_path, config.ENV_FILES_PATH) + use_default_envs = False if structure == "single": - file_paths[".env"] = os.path.join(custom_base, ".env") + file_paths[".env"] = os.path.join(dir_base, ".env") elif structure == "flat": for pattern in patterns: - file_paths[pattern] = os.path.join(custom_base, pattern) + file_paths[pattern] = os.path.join(dir_base, pattern) elif structure == "nested": - env_dir = ( - os.path.join(custom_base, environment) - if config.ENV_FILES_PATH - else os.path.join(custom_base, ".envs", environment) - ) + if use_default_envs: + env_dir = os.path.join(dir_base, ".envs", environment) + else: + env_dir = os.path.join(dir_base, environment) + for pattern in patterns: file_paths[pattern] = os.path.join(env_dir, pattern) - elif structure == "custom": - custom_path = config.ENV_FILES_PATH or base_path - for pattern in patterns: - file_paths[pattern] = os.path.join(custom_path, pattern) return file_paths @@ -118,37 +192,123 @@ def merge_env_vars_by_priority( ) -> Dict[str, str]: """Merge environment variables with proper priority""" merged = {} - file_pattern = pattern.replace(".env.", "").upper() - for var_name, value in all_env_vars.items(): - if var_name.startswith(f"ENV_{file_pattern}_"): - key = var_name.split("_", 2)[-1] - merged[key] = value - env_prefix = f"ENV_{environment.upper()}_{file_pattern}_" - for var_name, value in all_env_vars.items(): - if var_name.startswith(env_prefix): - key = var_name.split("_", 3)[-1] - merged[key] = value - all_in_one_key = f"ENV_{environment.upper()}_{file_pattern}" - if all_in_one_key in all_env_vars: - parsed = parse_all_in_one_secret(all_env_vars[all_in_one_key], config.ENV_FILES_FORMAT) - merged.update(parsed) - base_all_in_one_key = f"ENV_{file_pattern}" - if base_all_in_one_key in all_env_vars: - parsed = parse_all_in_one_secret(all_env_vars[base_all_in_one_key], config.ENV_FILES_FORMAT) - for key, value in parsed.items(): - if key not in merged: - merged[key] = value + env_upper = (environment or "").upper() + + # 1. Handle Global File (.env) - Retain Component Prefixes to avoid collisions + if pattern == ".env": + # Base variables (ENV_X) + for k, v in all_env_vars.items(): + if k.startswith("ENV_"): + # Skip internal configuration (ENV_FILES_*) + if k.startswith("ENV_FILES_"): + continue + + # Skip environment-specific ones + if any( + k.startswith(f"ENV_{e}_") + for e in ["PROD", "STAGING", "DEV", "TEST", "PRODUCTION"] + ): + continue + + key = k[4:] # e.g. APP or APP_PORT + parsed = parse_all_in_one_secret(v, config.ENV_FILES_FORMAT) + if parsed: + # If it's a component-level blob (e.g. ENV_APP), prefix its contents + # e.g. ENV_APP -> BASE_URL becomes APP_BASE_URL + # Unless it's already an individual var (key has underscore) + if "_" in key: + merged[key] = v + else: + for pk, pv in parsed.items(): + p_key = pk if pk.startswith(f"{key.upper()}_") else f"{key}_{pk}" + merged[p_key] = pv + else: + merged[key] = v + + # Environment-specific overrides (ENV_PROD_X) + prefix = f"ENV_{env_upper}_" + for k, v in all_env_vars.items(): + if k.startswith(prefix): + key = k[len(prefix) :] + parsed = parse_all_in_one_secret(v, config.ENV_FILES_FORMAT) + if parsed: + if "_" in key: + merged[key] = v + else: + for pk, pv in parsed.items(): + p_key = pk if pk.startswith(f"{key.upper()}_") else f"{key}_{pk}" + merged[p_key] = pv + else: + merged[key] = v + return merged + + # 2. Handle Patterned Files (.env.component) - Clean keys + file_base = pattern.replace(".env.", "").upper() + + # Priority stages: + # A. Base Individual (ENV_APP_PORT) + # B. Base Blob (ENV_APP) + # C. Env Individual (ENV_PROD_APP_PORT) + # D. Env Blob (ENV_PROD_APP) + + # A. Prefix search (e.g. ENV_APP_...) + base_prefix = f"ENV_{file_base}_" + for k, v in all_env_vars.items(): + if k.startswith(base_prefix): + key = k[len(base_prefix) :] + merged[key] = v + + # B. Component Blob (e.g. ENV_APP) + base_blob_key = f"ENV_{file_base}" + if base_blob_key in all_env_vars: + parsed = parse_all_in_one_secret(all_env_vars[base_blob_key], config.ENV_FILES_FORMAT) + if parsed: + # Strip component prefix if present to stay consistent with A/C + for pk, pv in parsed.items(): + p_key = pk[len(f"{file_base}_") :] if pk.startswith(f"{file_base}_") else pk + merged[p_key] = pv + elif all_env_vars[base_blob_key].strip(): + merged[file_base] = all_env_vars[base_blob_key] + + # C. Env Specific Individual (e.g. ENV_PROD_APP_...) + env_prefix = f"ENV_{env_upper}_{file_base}_" + for k, v in all_env_vars.items(): + if k.startswith(env_prefix): + key = k[len(env_prefix) :] + merged[key] = v + + # D. Env Specific Blob (e.g. ENV_PROD_APP) + env_blob_key = f"ENV_{env_upper}_{file_base}" + if env_blob_key in all_env_vars: + parsed = parse_all_in_one_secret(all_env_vars[env_blob_key], config.ENV_FILES_FORMAT) + if parsed: + for pk, pv in parsed.items(): + p_key = pk[len(f"{file_base}_") :] if pk.startswith(f"{file_base}_") else pk + merged[p_key] = pv + elif all_env_vars[env_blob_key].strip(): + merged[file_base] = all_env_vars[env_blob_key] + return merged def detect_environment_secrets() -> Dict[str, Dict[str, str]]: """Auto-detect and parse environment-specific secrets with priority system""" - all_env_vars = {k: v for k, v in os.environ.items() if k.startswith("ENV_")} + all_env_vars = { + k: v + for k, v in os.environ.items() + if k.startswith("ENV_") and not k.startswith("ENV_FILES_") + } if not all_env_vars: return {} - patterns = detect_file_patterns(all_env_vars, config.ENV_FILES_STRUCTURE) - if config.ENV_FILES_PATTERNS and config.ENV_FILES_STRUCTURE != "auto": - patterns = [p.strip() for p in config.ENV_FILES_PATTERNS if p.strip()] + + structure = config.ENV_FILES_STRUCTURE + if structure == "single": + patterns = [".env"] + else: + patterns = detect_file_patterns(all_env_vars, structure, config.ENVIRONMENT) + if config.ENV_FILES_PATTERNS and structure != "auto": + patterns = [p.strip() for p in config.ENV_FILES_PATTERNS if p.strip()] + result = {} for pattern in patterns: merged_vars = merge_env_vars_by_priority(all_env_vars, config.ENVIRONMENT, pattern) @@ -163,37 +323,56 @@ def create_env_file(conn, file_path: str, env_vars: Dict[str, str]) -> None: return dir_path = os.path.dirname(file_path) if dir_path and dir_path != file_path: - conn.run(f"mkdir -p {dir_path}") + conn.run(f"mkdir -p {dir_path} && chmod 755 {dir_path}") env_content = "\n".join([f"{k}={v}" for k, v in env_vars.items()]) conn.run(f"cat > \"{file_path}\" << 'EOF'\n{env_content}\nEOF") - conn.run(f'chmod 600 "{file_path}"') + conn.run(f'chmod 644 "{file_path}"') def generate_env_files(conn) -> None: """Main function to generate environment files from secrets""" if not config.ENV_FILES_GENERATE: return + print("🔧 Generating environment files from secrets...") - env_file_data = detect_environment_secrets() - if not env_file_data: - print("ℹ\ufe0f No environment variables found to generate files") - return - all_merged_vars = {} - for pattern, env_vars in env_file_data.items(): + try: + all_env_vars = { + k: v + for k, v in os.environ.items() + if k.startswith("ENV_") and not k.startswith("ENV_FILES_") + } + env_file_data = detect_environment_secrets() + if not env_file_data: + print("ℹ\ufe0f No environment variables found to generate files") + return + + # Determine paths for ALL patterns together to allow proper auto-detection (nested vs flat) file_paths = determine_file_structure( config.ENV_FILES_STRUCTURE, - [pattern], + list(env_file_data.keys()), config.ENVIRONMENT, config.GIT_SUBDIR, ) - file_path = file_paths.get(pattern) - if file_path: - print(f"📝 Creating {file_path} with {len(env_vars)} variables") - create_env_file(conn, file_path, env_vars) - if config.ENV_FILES_CREATE_ROOT: - all_merged_vars.update(env_vars) - if config.ENV_FILES_CREATE_ROOT and all_merged_vars: - root_env_path = os.path.join(config.GIT_SUBDIR, ".env") - print(f"📝 Creating combined root {root_env_path} with {len(all_merged_vars)} variables") - create_env_file(conn, root_env_path, all_merged_vars) - print("✅ Environment files generated successfully") + + for pattern, env_vars in env_file_data.items(): + file_path = file_paths.get(pattern) + if file_path: + print(f"📝 Creating {file_path} with {len(env_vars)} variables") + create_env_file(conn, file_path, env_vars) + + if config.ENV_FILES_CREATE_ROOT: + # Generate the root file as if it were 'single' structure to ensure consistent prefixing + root_vars = merge_env_vars_by_priority(all_env_vars, config.ENVIRONMENT, ".env") + root_env_path = os.path.join(config.GIT_SUBDIR, ".env") + if root_vars: + print(f"📝 Creating combined root {root_env_path} with {len(root_vars)} variables") + create_env_file(conn, root_env_path, root_vars) + + print("✅ Environment files generated successfully") + + except Exception as e: + print(f"⚠\ufe0f Error processing environment logic: {e}") + import traceback + + traceback.print_exc() + print(" Continuing deployment without environment files...") From 9100060c4f6fc2a24eca9699524c61600d5de35b Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:29:18 +0100 Subject: [PATCH 05/13] test(coverage): improve test coverage --- coverage.xml | 781 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 781 insertions(+) create mode 100644 coverage.xml diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..675da1e --- /dev/null +++ b/coverage.xml @@ -0,0 +1,781 @@ + + + + + + /workspace/personal/MetalDeploy/src + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0a5af77c676daea99e8cf19db8ff39e3ed7d4f56 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:29:28 +0100 Subject: [PATCH 06/13] test(unit-tests): improve unit test reliability --- tests/unit/test_connection.py | 74 ++++++++++++++++++++++ tests/unit/test_db_utils.py | 77 +++++++++++++++++++++++ tests/unit/test_env_fixes.py | 41 ++++++++++++ tests/unit/test_env_manager.py | 90 +++++++++++++++++++++++++++ tests/unit/test_errors.py | 71 +++++++++++++++++++++ tests/unit/test_git_ops.py | 107 ++++++++++++++++++++++++++++++++ tests/unit/test_orchestrator.py | 77 +++++++++++++++++++++++ tests/unit/test_providers.py | 96 ++++++++++++++++++++++++++++ 8 files changed, 633 insertions(+) create mode 100644 tests/unit/test_connection.py create mode 100644 tests/unit/test_db_utils.py create mode 100644 tests/unit/test_env_fixes.py create mode 100644 tests/unit/test_env_manager.py create mode 100644 tests/unit/test_errors.py create mode 100644 tests/unit/test_git_ops.py create mode 100644 tests/unit/test_orchestrator.py create mode 100644 tests/unit/test_providers.py diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py new file mode 100644 index 0000000..87a369f --- /dev/null +++ b/tests/unit/test_connection.py @@ -0,0 +1,74 @@ +import base64 +import os +from unittest.mock import Mock + +import pytest +from fabric import Connection + +from src.config import config +from src.connection import install_dependencies, run_command, setup_ssh_key + + +@pytest.fixture +def mock_conn(): + conn = Mock(spec=Connection) + conn.run.return_value = Mock(stdout="", ok=True) + return conn + + +def test_setup_ssh_key_raw(monkeypatch): + key = "raw-ssh-key-content" + monkeypatch.setattr(config, "SSH_KEY", key) + setup_ssh_key() + assert config.SSH_KEY_PATH is not None + with open(config.SSH_KEY_PATH, "r") as f: + assert f.read() == key + os.unlink(config.SSH_KEY_PATH) + + +def test_setup_ssh_key_base64(monkeypatch): + key = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" + b64_key = base64.b64encode(key.encode()).decode() + monkeypatch.setattr(config, "SSH_KEY", b64_key) + setup_ssh_key() + assert config.SSH_KEY_PATH is not None + with open(config.SSH_KEY_PATH, "r") as f: + assert f.read() == key + os.unlink(config.SSH_KEY_PATH) + + +def test_run_command_sudo_wrapping(mock_conn, monkeypatch): + monkeypatch.setattr(config, "USE_SUDO", True) + monkeypatch.setattr(config, "REMOTE_USER", "deploy") + run_command(mock_conn, "test-cmd") + + call_args = mock_conn.run.call_args[0][0] + assert "sudo" in call_args + assert "bash -l -c" in call_args + assert "source /home/deploy/.bashrc" in call_args + + +def test_run_command_password_sudo(mock_conn, monkeypatch): + monkeypatch.setattr(config, "USE_SUDO", True) + monkeypatch.setattr(config, "REMOTE_PASSWORD", "mypass") + run_command(mock_conn, "test-cmd") + + call_args = mock_conn.run.call_args[0][0] + assert "printf '%s\\n' 'mypass' | sudo -S" in call_args + + +def test_install_dependencies_missing(mock_conn): + # Mocking 'which' results: git exists, others don't + mock_conn.run.side_effect = [ + Mock(stdout="/usr/bin/git"), # git + Mock(stdout=""), # pip + Mock(stdout=""), # dev + Mock(stdout=""), # build-essential + Mock(stdout=""), # libssl + Mock(stdout=""), # libffi + Mock(ok=True), # apt update + Mock(ok=True), # apt install + ] + install_dependencies(mock_conn) + # 6 'which' calls + 2 installations = 8 calls + assert mock_conn.run.call_count >= 8 diff --git a/tests/unit/test_db_utils.py b/tests/unit/test_db_utils.py new file mode 100644 index 0000000..32bdc98 --- /dev/null +++ b/tests/unit/test_db_utils.py @@ -0,0 +1,77 @@ +from unittest.mock import Mock, patch + +import pytest +from fabric import Connection + +from src.config import config +from src.providers import utils + + +@pytest.fixture +def mock_conn(): + conn = Mock(spec=Connection) + conn.run.return_value = Mock(stdout="", ok=True) + conn.cd.return_value.__enter__ = Mock(return_value=None) + conn.cd.return_value.__exit__ = Mock(return_value=None) + return conn + + +def test_detect_db_postgres(mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_SUBDIR", "/app") + + def mock_run(cmd, **kwargs): + if "test -f" in cmd: + if "docker-compose.yml" in cmd: + return Mock(stdout="exists") + return Mock(stdout="not exists") + if "grep" in cmd: + if "postgres" in cmd: + return Mock(stdout="image: postgres") + return Mock(stdout="") + return Mock(stdout="") + + mock_conn.run.side_effect = mock_run + dbs = utils.detect_database_type(mock_conn) + assert "postgres" in dbs + + +def test_detect_db_multiple(mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_SUBDIR", "/app") + + def mock_run(cmd, **kwargs): + if "test -f" in cmd: + if "docker-compose.yml" in cmd: + return Mock(stdout="exists") + return Mock(stdout="not exists") + if "grep" in cmd: + if "postgres" in cmd or "redis" in cmd: + return Mock(stdout="match") + return Mock(stdout="") + return Mock(stdout="") + + mock_conn.run.side_effect = mock_run + dbs = utils.detect_database_type(mock_conn) + assert "postgres" in dbs + assert "redis" in dbs + + +def test_fix_permissions_logic(mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_SUBDIR", "/app") + + def mock_run(cmd, **kwargs): + if "test -f" in cmd: + return Mock(stdout="exists") + if "grep" in cmd: + return Mock(stdout="postgres") + if "find" in cmd: + return Mock(stdout="./pgdata") + return Mock(stdout="") + + mock_conn.run.side_effect = mock_run + + with patch("src.providers.utils.run_command") as mock_run_cmd: + utils.fix_database_permissions(mock_conn) + assert mock_run_cmd.called + args = mock_run_cmd.call_args[0][1] + assert "chown -R 999:999" in args + assert "./pgdata" in args diff --git a/tests/unit/test_env_fixes.py b/tests/unit/test_env_fixes.py new file mode 100644 index 0000000..bf73ba9 --- /dev/null +++ b/tests/unit/test_env_fixes.py @@ -0,0 +1,41 @@ +from src.env_manager import detect_file_patterns + + +def test_detect_patterns_generic_app(): + """Test generic ENV_APP detection with prod environment.""" + # Scenario: ENV_APP is defined (no suffix), environment is prod + env_vars = {"ENV_APP": "PORT=8000", "ENV_FILES_GENERATE": "true"} + environment = "prod" + patterns = detect_file_patterns(env_vars, "auto", environment) + # Should detect .env.app + assert ".env.app" in patterns + assert len(patterns) == 1 + + +def test_detect_patterns_prod_app(): + """Test environment specific ENV_PROD_APP detection (prior fix).""" + env_vars = {"ENV_PROD_APP": "PORT=9000", "ENV_FILES_GENERATE": "true"} + environment = "prod" + patterns = detect_file_patterns(env_vars, "auto", environment) + assert ".env.app" in patterns + + +def test_detect_patterns_mixed(): + """Test mixture of generic and specific vars.""" + env_vars = { + "ENV_APP": "COMMON=1", + "ENV_PROD_DATABASE": "DB_HOST=localhost", + "ENV_REDIS": "redis", # Should detect .env.redis + } + environment = "prod" + patterns = detect_file_patterns(env_vars, "auto", environment) + assert ".env.redis" in patterns + + +def test_detect_patterns_ignore_config(): + """Ensure ENV_FILES_ config vars don't become files.""" + env_vars = {"ENV_FILES_STRUCTURE": "auto"} + patterns = detect_file_patterns(env_vars, "auto", "prod") + # Should default to .env.app if nothing else found, or empty? + # Logic returns [".env.app"] if patterns is empty. + assert patterns == [".env.app"] diff --git a/tests/unit/test_env_manager.py b/tests/unit/test_env_manager.py new file mode 100644 index 0000000..0c32013 --- /dev/null +++ b/tests/unit/test_env_manager.py @@ -0,0 +1,90 @@ +import os +from unittest.mock import Mock, patch + +import pytest +from fabric import Connection + +from src.config import config +from src.env_manager import ( + create_env_file, + detect_file_patterns, + determine_file_structure, + generate_env_files, + parse_all_in_one_secret, +) + + +@pytest.fixture +def mock_conn(): + conn = Mock(spec=Connection) + conn.run.return_value = Mock(stdout="", ok=True) + conn.cd.return_value.__enter__ = Mock(return_value=None) + conn.cd.return_value.__exit__ = Mock(return_value=None) + return conn + + +def test_parse_formats(): + assert parse_all_in_one_secret("K1=V1\nK2=V2", "env") == {"K1": "V1", "K2": "V2"} + assert parse_all_in_one_secret('{"K1": "V1"}', "json") == {"K1": "V1"} + assert parse_all_in_one_secret("K1: V1", "yaml") == {"K1": "V1"} + + +def test_pattern_detection(): + # Variable names must end with _ or have more parts to trigger regex correctly + env_vars = {"ENV_APP_PORT": "80", "ENV_DATABASE_URL": "...", "ENV_REDIS_HOST": "..."} + patterns = detect_file_patterns(env_vars, "nested") + assert ".env.app" in patterns + assert ".env.database" in patterns + assert ".env.redis" in patterns + + +def test_structure_nested(monkeypatch): + monkeypatch.setattr(config, "ENV_FILES_PATH", None) + paths = determine_file_structure("nested", [".env.app"], "prod", "/app") + assert paths[".env.app"] == "/app/.envs/prod/.env.app" + + +def test_root_mega_file_creation(mock_conn, monkeypatch): + test_secrets = { + "ENV_APP_V1": "val1", + "ENV_DB_V2": "val2", + "ENV_FILES_GENERATE": "true", + "ENV_FILES_CREATE_ROOT": "true", + "ENV_FILES_STRUCTURE": "flat", + "ENV_FILES_PATTERNS": "", + } + + with patch.dict(os.environ, test_secrets, clear=False): + config.load() + # Force these values and a predictable subdir + config.ENV_FILES_GENERATE = True + config.ENV_FILES_CREATE_ROOT = True + config.ENV_FILES_STRUCTURE = "flat" + config.ENV_FILES_PATTERNS = [] + config.GIT_SUBDIR = "/testing" + + with patch("src.env_manager.create_env_file") as mock_create: + generate_env_files(mock_conn) + + # We expect .env.app, .env.db and root .env + # (plus maybe .env.files based on the shared ENV_ prefix bug) + assert mock_create.call_count >= 3 + + # Look for the root .env in our testing subdir + root_calls = [c for c in mock_create.call_args_list if c[0][1] == "/testing/.env"] + assert len(root_calls) == 1 + merged_vars = root_calls[0][0][2] + assert "APP_V1" in merged_vars + assert "DB_V2" in merged_vars + + +def test_heredoc_escaping(mock_conn): + env_vars = {"KEY": "val$with$dollars"} + create_env_file(mock_conn, ".env", env_vars) + found_secure_cat = False + for call in mock_conn.run.call_args_list: + cmd = call[0][0] + if "cat >" in cmd and "<< 'EOF'" in cmd: + found_secure_cat = True + assert "KEY=val$with$dollars" in cmd + assert found_secure_cat diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py new file mode 100644 index 0000000..cf6a2ba --- /dev/null +++ b/tests/unit/test_errors.py @@ -0,0 +1,71 @@ +from unittest.mock import Mock, patch + +import pytest +from fabric import Connection + +from src import git_ops, orchestrator, providers +from src.config import config + + +@pytest.fixture +def mock_conn(): + conn = Mock(spec=Connection) + conn.run.return_value = Mock(stdout="", ok=True) + conn.cd.return_value.__enter__ = Mock(return_value=None) + conn.cd.return_value.__exit__ = Mock(return_value=None) + return conn + + +class TestErrorScenarios: + def test_ssh_connection_failure(self, monkeypatch): + monkeypatch.setattr(config, "REMOTE_HOST", "bad-host") + # Set auth method to none to avoid early exit in setup_git_auth + monkeypatch.setattr(config, "GIT_AUTH_METHOD", "none") + with patch("src.orchestrator.Connection", side_effect=Exception("SSH Timeout")): + with patch("src.orchestrator.setup_ssh_key"): + # Use handle_connection to trigger the Connection call + with pytest.raises(Exception, match="SSH Timeout"): + orchestrator.handle_connection() + + def test_git_clone_failure(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_AUTH_METHOD", "none") + monkeypatch.setattr(config, "REMOTE_DIR", "/app") + # dir check -> not exists, then clone -> fails + mock_conn.run.side_effect = [ + Mock(ok=True), # mkdir + Mock(stdout="not exists"), + Exception("Git clone failed"), + ] + with pytest.raises(Exception, match="Git clone failed"): + git_ops.clone_repo(mock_conn) + + def test_baremetal_deploy_script_fail(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_SUBDIR", "/app") + # deploy.sh exists, but run_command (which we'll mock) fails + mock_conn.run.side_effect = [ + Mock(ok=True), # test -f deploy.sh + Mock(ok=True), # chmod +x + ] + with patch("src.providers.baremetal.run_command") as mock_run: + mock_run.return_value = Mock(stdout="Command failed with exit code: 1") + with pytest.raises(ValueError, match="deploy.sh failed with exit code: 1"): + providers.baremetal.deploy_baremetal(mock_conn) + + def test_docker_login_missing_creds(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_USER", None) + with pytest.raises(ValueError, match="GIT_USER and GIT_TOKEN must be set"): + providers.docker.docker_login(mock_conn, registry_type="ghcr") + + def test_missing_k8s_manifests(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_SUBDIR", "/app") + monkeypatch.setattr(config, "K8S_MANIFEST_PATH", None) + # All manifest directory/file checks return False + mock_conn.run.return_value = Mock(ok=False) + + with patch("src.providers.k8s.docker_login"): + with pytest.raises(ValueError, match="No k8s_manifest_path specified"): + providers.k8s.deploy_k8s(mock_conn) + + def test_unsupported_registry(self, mock_conn): + with pytest.raises(ValueError, match="Unsupported registry_type"): + providers.docker.docker_login(mock_conn, registry_type="unknown") diff --git a/tests/unit/test_git_ops.py b/tests/unit/test_git_ops.py new file mode 100644 index 0000000..324e810 --- /dev/null +++ b/tests/unit/test_git_ops.py @@ -0,0 +1,107 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest +from fabric import Connection + +from src import git_ops +from src.config import config + + +@pytest.fixture +def mock_conn(): + conn = MagicMock(spec=Connection) + conn.run.return_value = Mock(stdout="", ok=True) + conn.cd.return_value.__enter__ = Mock(return_value=None) + conn.cd.return_value.__exit__ = Mock(return_value=None) + return conn + + +def test_git_auth_token_setup(monkeypatch): + monkeypatch.setattr(config, "GIT_AUTH_METHOD", "token") + monkeypatch.setattr(config, "GIT_TOKEN", "token123") + monkeypatch.setattr(config, "GIT_USER", "user123") + monkeypatch.setattr(config, "GIT_URL", "https://github.com/org/repo.git") + git_ops.setup_git_auth() + assert config.AUTH_GIT_URL == "https://user123:token123@github.com/org/repo.git" + + +def test_git_auth_ssh_conversion(monkeypatch): + monkeypatch.setattr(config, "GIT_AUTH_METHOD", "ssh") + monkeypatch.setattr(config, "GIT_SSH_KEY", "-----BEGIN PRIVATE KEY-----") + monkeypatch.setattr(config, "GIT_URL", "https://github.com/org/repo.git") + with patch("src.git_ops.tempfile.NamedTemporaryFile") as mock_tmp, patch("os.chmod"): + mock_tmp.return_value.__enter__.return_value.name = "/tmp/key" + git_ops.setup_git_auth() + assert config.AUTH_GIT_URL == "git@github.com:org/repo.git" + + +def test_clone_new_repository(mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_DIR", "/app/repo") + monkeypatch.setattr(config, "REMOTE_DIR", "/app") + monkeypatch.setattr(config, "PROJECT_NAME", "repo") + monkeypatch.setattr(config, "GIT_SUBDIR", "/app/repo") + monkeypatch.setattr(config, "ENVIRONMENT", "main") + + mock_conn.run.side_effect = [ + Mock(ok=True), # mkdir + Mock(stdout="not exists"), # dir check + Mock(ok=True), # git clone + Mock(ok=True), # safe.directory + Mock(ok=True), # chown + Mock(stdout="main"), # rev-parse (current branch) + Mock(ok=True), # fetch & reset + ] + + git_ops.clone_repo(mock_conn) + assert any("git clone" in str(call) for call in mock_conn.run.call_args_list) + + +def test_clone_existing_needs_checkout(mock_conn, monkeypatch): + monkeypatch.setattr(config, "ENVIRONMENT", "staging") + monkeypatch.setattr(config, "GIT_DIR", "/app/repo") + monkeypatch.setattr(config, "GIT_SUBDIR", "/app/repo") + + mock_conn.run.side_effect = [ + Mock(ok=True), # mkdir + Mock(stdout="exists"), # dir exists + Mock(stdout="git_repo"), # is git repo + Mock(ok=True), # safe.directory + Mock(ok=True), # chown + Mock(stdout="main"), # rev-parse -> we are on main, but env is staging + Mock(ok=True), # git stash + Mock(ok=True), # git checkout staging + Mock(ok=True), # fetch & reset + ] + + git_ops.clone_repo(mock_conn) + calls = [str(call) for call in mock_conn.run.call_args_list] + assert any("git checkout staging" in c for c in calls) + assert any("git stash" in c for c in calls) + + +def test_clone_ssh_auth_flow(mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_AUTH_METHOD", "ssh") + monkeypatch.setattr(config, "GIT_SSH_KEY_PATH", "/tmp/key") + monkeypatch.setattr(config, "PROJECT_NAME", "repo") + monkeypatch.setattr(config, "GIT_DIR", "/app/repo") + + mock_conn.run.side_effect = [ + Mock(ok=True), # mkdir + Mock(ok=True), # chmod key + Mock(ok=True), # mkdir .ssh + Mock(ok=True), # echo config + Mock(ok=True), # chmod config + Mock(stdout="not exists"), # dir check + Mock(ok=True), # git clone + Mock(ok=True), # safe.directory + Mock(ok=True), # chown + Mock(stdout="dev"), # rev-parse + Mock(ok=True), # fetch & reset + ] + + git_ops.clone_repo(mock_conn) + # Check if key was put to remote + assert mock_conn.put.called + # Check if GIT_SSH_COMMAND was used + clone_call = [c for c in mock_conn.run.call_args_list if "git clone" in str(c)][0] + assert "GIT_SSH_COMMAND" in clone_call[0][0] diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py new file mode 100644 index 0000000..06a2066 --- /dev/null +++ b/tests/unit/test_orchestrator.py @@ -0,0 +1,77 @@ +from unittest.mock import Mock, patch + +import pytest +from fabric import Connection + +from src import orchestrator +from src.config import config + + +@pytest.fixture +def mock_conn_class(): + with patch("src.orchestrator.Connection") as mock: + yield mock + + +@pytest.fixture +def mock_conn(): + conn = Mock(spec=Connection) + conn.run.return_value = Mock(stdout="test-host", ok=True) + conn.cd.return_value.__enter__ = Mock(return_value=None) + conn.cd.return_value.__exit__ = Mock(return_value=None) + return conn + + +def test_handle_connection_flow(mock_conn_class, mock_conn, monkeypatch, tmp_path): + # Setup + monkeypatch.setattr(config, "REMOTE_HOST", "1.2.3.4") + monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) + monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "baremetal") + + github_output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + mock_conn_class.return_value = mock_conn + + # Mocking all dependencies + with ( + patch("src.orchestrator.setup_ssh_key"), + patch("src.orchestrator.setup_git_auth"), + patch("src.orchestrator.install_dependencies"), + patch("src.orchestrator.clone_repo"), + patch("src.orchestrator.generate_env_files"), + patch("src.orchestrator.deploy"), + ): + orchestrator.handle_connection() + + # Verify outputs + content = github_output.read_text() + assert "remote_hostname=test-host" in content + assert "deployment_status=success" in content + + +def test_deploy_routing(monkeypatch, mock_conn): + # 1. Test custom deploy command + monkeypatch.setattr(config, "DEPLOY_COMMAND", "echo hello") + with patch("src.orchestrator.run_command") as mock_run: + orchestrator.deploy(mock_conn) + assert "echo hello" in mock_run.call_args[0][1] + + # 2. Test barefoot routing + monkeypatch.setattr(config, "DEPLOY_COMMAND", None) + monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "baremetal") + with patch("src.orchestrator.deploy_baremetal") as mock_bm: + orchestrator.deploy(mock_conn) + mock_bm.assert_called_once() + + # 3. Test docker routing + monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "docker") + with patch("src.orchestrator.deploy_docker") as mock_dk: + orchestrator.deploy(mock_conn) + mock_dk.assert_called_once() + + # 4. Test k8s routing + monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "k8s") + with patch("src.orchestrator.deploy_k8s") as mock_k8s: + orchestrator.deploy(mock_conn) + mock_k8s.assert_called_once() diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py new file mode 100644 index 0000000..24334eb --- /dev/null +++ b/tests/unit/test_providers.py @@ -0,0 +1,96 @@ +from unittest.mock import Mock, patch + +import pytest +from fabric import Connection + +from src.config import config +from src.providers import baremetal, docker, k8s + + +@pytest.fixture +def mock_conn(): + conn = Mock(spec=Connection) + conn.run.return_value = Mock(stdout="", ok=True) + conn.cd.return_value.__enter__ = Mock(return_value=None) + conn.cd.return_value.__exit__ = Mock(return_value=None) + return conn + + +class TestBaremetal: + def test_deploy_sh_flow(self, mock_conn): + def mock_run(cmd, **kwargs): + if "test -f deploy.sh" in cmd: + return Mock(ok=True) + return Mock(ok=False) + + mock_conn.run.side_effect = mock_run + + with patch("src.providers.baremetal.run_command") as mock_run_cmd: + baremetal.deploy_baremetal(mock_conn) + assert "./deploy.sh" in mock_run_cmd.call_args[0][1] + + def test_makefile_fallback(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "ENVIRONMENT", "staging") + + # deploy.sh no, Makefile yes + def mock_run(cmd, **kwargs): + if "test -f deploy.sh" in cmd: + return Mock(ok=False) + if "test -f Makefile" in cmd: + return Mock(ok=True) + return Mock(ok=False) + + mock_conn.run.side_effect = mock_run + + with patch("src.providers.baremetal.run_command") as mock_run_cmd: + baremetal.deploy_baremetal(mock_conn) + assert "make staging" in mock_run_cmd.call_args[0][1] + + +class TestDocker: + def test_docker_login_ghcr(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "GIT_USER", "u") + monkeypatch.setattr(config, "GIT_TOKEN", "t") + docker.docker_login(mock_conn, registry_type="ghcr") + assert "docker login ghcr.io" in mock_conn.run.call_args[0][0] + + def test_deploy_docker_with_profile(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "PROFILE", "api") + monkeypatch.setattr(config, "GIT_SUBDIR", "/app") + with patch("src.providers.docker.docker_login"), patch( + "src.providers.docker.run_command" + ) as mock_run: + docker.deploy_docker(mock_conn) + assert "--profile api" in mock_run.call_args[0][1] + + +class TestK8s: + def test_k3s_installation(self, mock_conn): + mock_conn.run.side_effect = [ + Mock(stdout=""), # which k3s -> not found + Mock(ok=True), # curl k3s sh + Mock(ok=True), # systemctl enable + Mock(ok=True), # systemctl start + Mock(ok=True), # echo KUBECONFIG + ] + with patch("src.providers.k8s.run_command"): + k8s.install_k3s(mock_conn) + assert mock_conn.run.call_count >= 3 + + def test_k8s_deploy_namespace_creation(self, mock_conn, monkeypatch): + monkeypatch.setattr(config, "K8S_NAMESPACE", "custom-ns") + monkeypatch.setattr(config, "K8S_MANIFEST_PATH", "k8s/") + monkeypatch.setattr(config, "GIT_SUBDIR", "/app") + + mock_conn.run.side_effect = [ + Mock(ok=True), # test -d k8s + Mock(ok=True), # create namespace + Mock(ok=True), # apply -f k8s/ + ] + + with patch("src.providers.k8s.docker_login"): + k8s.deploy_k8s(mock_conn) + + calls = [str(call) for call in mock_conn.run.call_args_list] + assert any("kubectl create namespace custom-ns" in c for c in calls) + assert any("kubectl apply -f k8s/" in c for c in calls) From eec7a57330b440c53e1420b74533332d9fbddff0 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:29:37 +0100 Subject: [PATCH 07/13] chore(code-organization): remove redundant files --- .github/workflows/example-baremetal.yml | 26 ------------ .github/workflows/example-docker.yml | 30 -------------- .github/workflows/example-k8s.yml | 27 ------------- tests/test_integration.py | 53 ------------------------- 4 files changed, 136 deletions(-) delete mode 100644 .github/workflows/example-baremetal.yml delete mode 100644 .github/workflows/example-docker.yml delete mode 100644 .github/workflows/example-k8s.yml delete mode 100644 tests/test_integration.py diff --git a/.github/workflows/example-baremetal.yml b/.github/workflows/example-baremetal.yml deleted file mode 100644 index 77223c2..0000000 --- a/.github/workflows/example-baremetal.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Deploy to VPS (Baremetal) - -# This is an EXAMPLE workflow - it will NOT trigger automatically -# Copy this to your own repository and customize as needed -on: - workflow_dispatch: # Manual trigger only - won't run automatically - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Deploy to VPS (Baremetal) - uses: OpsGuild/VPS-Deploy@v1 - with: - git_url: ${{ secrets.GIT_URL }} - git_auth_method: token - git_token: ${{ secrets.GITHUB_TOKEN }} - git_user: ${{ github.actor }} - remote_host: ${{ secrets.VPS_HOST }} - ssh_key: ${{ secrets.SSH_PRIVATE_KEY }} - deployment_type: baremetal - environment: prod - # Optional: specify custom command - # baremetal_command: "npm install && npm run build && pm2 restart app" diff --git a/.github/workflows/example-docker.yml b/.github/workflows/example-docker.yml deleted file mode 100644 index c88bd1d..0000000 --- a/.github/workflows/example-docker.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Deploy to VPS (Docker) - -# This is an EXAMPLE workflow - it will NOT trigger automatically -# Copy this to your own repository and customize as needed -on: - workflow_dispatch: # Manual trigger only - won't run automatically - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Deploy to VPS (Docker) - uses: OpsGuild/VPS-Deploy@v1 - with: - git_url: ${{ secrets.GIT_URL }} - git_auth_method: token - git_token: ${{ secrets.GITHUB_TOKEN }} - git_user: ${{ github.actor }} - remote_host: ${{ secrets.VPS_HOST }} - ssh_key: ${{ secrets.SSH_PRIVATE_KEY }} - deployment_type: docker - environment: prod - profile: production - registry_type: ghcr - # Or use Docker Hub: - # registry_type: dockerhub - # registry_username: ${{ secrets.DOCKERHUB_USERNAME }} - # registry_password: ${{ secrets.DOCKERHUB_PASSWORD }} diff --git a/.github/workflows/example-k8s.yml b/.github/workflows/example-k8s.yml deleted file mode 100644 index 6235bfd..0000000 --- a/.github/workflows/example-k8s.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Deploy to VPS (Kubernetes) - -# This is an EXAMPLE workflow - it will NOT trigger automatically -# Copy this to your own repository and customize as needed -on: - workflow_dispatch: # Manual trigger only - won't run automatically - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Deploy to VPS (Kubernetes) - uses: OpsGuild/VPS-Deploy@v1 - with: - git_url: ${{ secrets.GIT_URL }} - git_auth_method: token - git_token: ${{ secrets.GITHUB_TOKEN }} - git_user: ${{ github.actor }} - remote_host: ${{ secrets.VPS_HOST }} - ssh_key: ${{ secrets.SSH_PRIVATE_KEY }} - deployment_type: k8s - environment: prod - k8s_manifest_path: k8s/ - k8s_namespace: production - registry_type: ghcr diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 5e59bb6..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,53 +0,0 @@ -from unittest.mock import MagicMock, Mock, patch - -import pytest -from fabric import Connection - -from src import orchestrator -from src.config import config - - -@pytest.fixture -def mock_conn(): - conn = MagicMock(spec=Connection) - conn.run.return_value = Mock(stdout="test-node", ok=True) - conn.cd.return_value.__enter__ = Mock(return_value=None) - conn.cd.return_value.__exit__ = Mock(return_value=None) - return conn - - -def test_full_baremetal_flow(mock_conn, monkeypatch, tmp_path): - # Integration-like test for the whole orchestrator - github_output = tmp_path / "github_output" - monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) - - monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "baremetal") - monkeypatch.setattr(config, "REMOTE_HOST", "my-server") - monkeypatch.setattr(config, "GIT_URL", "https://github.com/org/repo") - - with patch("src.orchestrator.Connection", return_value=mock_conn), patch( - "src.orchestrator.setup_ssh_key" - ), patch("src.orchestrator.setup_git_auth"), patch( - "src.orchestrator.install_dependencies" - ), patch( - "src.orchestrator.clone_repo" - ), patch( - "src.orchestrator.deploy" - ) as mock_deploy: - orchestrator.handle_connection() - - assert mock_deploy.called - output = github_output.read_text() - assert "deployment_status=success" in output - assert "remote_hostname=test-node" in output - - -def test_config_reload_behavior(monkeypatch): - # Test that config.load() correctly updates the config instance - monkeypatch.setenv("ENV_FILES_STRUCTURE", "flat") - config.load() - assert config.ENV_FILES_STRUCTURE == "flat" - - monkeypatch.setenv("ENV_FILES_STRUCTURE", "nested") - config.load() - assert config.ENV_FILES_STRUCTURE == "nested" From d13806fcee6624db1edf03a16554be40082917dc Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:29:42 +0100 Subject: [PATCH 08/13] ci: Update test workflow to use make commands --- .github/workflows/test.yml | 48 ++++---------------------------------- 1 file changed, 5 insertions(+), 43 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 033f690..1a3f3c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,9 +35,8 @@ jobs: run: | poetry install --with dev - - name: Run unit tests - run: | - poetry run pytest tests/ -v --cov=src --cov=main --cov-report=xml --cov-report=html -m "not integration and not slow" + - name: Run tests + run: make test - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 @@ -48,28 +47,7 @@ jobs: fail_ci_if_error: false - name: Validate action.yml - run: | - # Check if action.yml is valid YAML - poetry run python -c "import yaml; yaml.safe_load(open('action.yml'))" - echo "✅ action.yml is valid YAML" - - # Check for required fields - if ! grep -q "name:" action.yml; then - echo "❌ Missing 'name' in action.yml" - exit 1 - fi - - if ! grep -q "description:" action.yml; then - echo "❌ Missing 'description' in action.yml" - exit 1 - fi - - if ! grep -q "runs:" action.yml; then - echo "❌ Missing 'runs' in action.yml" - exit 1 - fi - - echo "✅ action.yml validation passed" + run: make validate - name: Check Python syntax run: | @@ -98,21 +76,5 @@ jobs: run: | poetry install --with dev - - name: Run unit tests - run: | - poetry run pytest tests/ -v -m "not integration and not slow" - - - name: Run flake8 - run: | - poetry run flake8 . --max-line-length=100 --ignore=E501,W503 --exclude=.git,__pycache__,*.pyc,.pytest_cache,.venv,venv - continue-on-error: true - - - name: Check code formatting with black - run: | - poetry run black --check . - continue-on-error: true - - - name: Check import sorting with isort - run: | - poetry run isort --check-only . - continue-on-error: true + - name: Run linting + run: make lint From ab12eca1e4c76428ca10a0409fa372ce085f1de4 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:37:17 +0100 Subject: [PATCH 09/13] chore: exclude generated_envs from version control and pre-commit checks --- .gitignore | 2 + .pre-commit-config.yaml | 1 + changelogs/2026-01-29_14-25-29.md | 2 +- changelogs/2026-01-29_14-34-57.md | 9 +++ tests/test_connection.py | 74 --------------------- tests/test_db_utils.py | 77 --------------------- tests/test_env_manager.py | 90 ------------------------- tests/test_errors.py | 71 -------------------- tests/test_git_ops.py | 107 ------------------------------ tests/test_orchestrator.py | 77 --------------------- tests/test_providers.py | 96 --------------------------- 11 files changed, 13 insertions(+), 593 deletions(-) create mode 100644 changelogs/2026-01-29_14-34-57.md delete mode 100644 tests/test_connection.py delete mode 100644 tests/test_db_utils.py delete mode 100644 tests/test_env_manager.py delete mode 100644 tests/test_errors.py delete mode 100644 tests/test_git_ops.py delete mode 100644 tests/test_orchestrator.py delete mode 100644 tests/test_providers.py diff --git a/.gitignore b/.gitignore index de37e2c..e8d1fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ htmlcov/ .pytest_cache/ .tox/ .hypothesis/ +generated_envs/ +**/generated_envs/ # IDEs .vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6daaf9a..2aa65e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +exclude: '^tests/.*generated_envs/.*' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 diff --git a/changelogs/2026-01-29_14-25-29.md b/changelogs/2026-01-29_14-25-29.md index 879313d..66832a4 100644 --- a/changelogs/2026-01-29_14-25-29.md +++ b/changelogs/2026-01-29_14-25-29.md @@ -23,4 +23,4 @@ - Added a `validate` target to the Makefile to validate the `action.yml` file. - Added environment variable `ENV_FILES_PATTERNS` to allow custom patterns for environment files. - Added support for nested environment file structures. -- Added error handling for SSH connections and Git operations. \ No newline at end of file +- Added error handling for SSH connections and Git operations. diff --git a/changelogs/2026-01-29_14-34-57.md b/changelogs/2026-01-29_14-34-57.md new file mode 100644 index 0000000..bc1fd3a --- /dev/null +++ b/changelogs/2026-01-29_14-34-57.md @@ -0,0 +1,9 @@ +# Changelog + +## [Unreleased] + +### Added +- Exclusion of `generated_envs` directories from version control and pre-commit checks to prevent unnecessary checks and commits. + +### Changed +- Updated pre-commit configuration to exclude files within `tests/` directory that are in `generated_envs/` subdirectories, improving pre-commit hook performance. \ No newline at end of file diff --git a/tests/test_connection.py b/tests/test_connection.py deleted file mode 100644 index 87a369f..0000000 --- a/tests/test_connection.py +++ /dev/null @@ -1,74 +0,0 @@ -import base64 -import os -from unittest.mock import Mock - -import pytest -from fabric import Connection - -from src.config import config -from src.connection import install_dependencies, run_command, setup_ssh_key - - -@pytest.fixture -def mock_conn(): - conn = Mock(spec=Connection) - conn.run.return_value = Mock(stdout="", ok=True) - return conn - - -def test_setup_ssh_key_raw(monkeypatch): - key = "raw-ssh-key-content" - monkeypatch.setattr(config, "SSH_KEY", key) - setup_ssh_key() - assert config.SSH_KEY_PATH is not None - with open(config.SSH_KEY_PATH, "r") as f: - assert f.read() == key - os.unlink(config.SSH_KEY_PATH) - - -def test_setup_ssh_key_base64(monkeypatch): - key = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" - b64_key = base64.b64encode(key.encode()).decode() - monkeypatch.setattr(config, "SSH_KEY", b64_key) - setup_ssh_key() - assert config.SSH_KEY_PATH is not None - with open(config.SSH_KEY_PATH, "r") as f: - assert f.read() == key - os.unlink(config.SSH_KEY_PATH) - - -def test_run_command_sudo_wrapping(mock_conn, monkeypatch): - monkeypatch.setattr(config, "USE_SUDO", True) - monkeypatch.setattr(config, "REMOTE_USER", "deploy") - run_command(mock_conn, "test-cmd") - - call_args = mock_conn.run.call_args[0][0] - assert "sudo" in call_args - assert "bash -l -c" in call_args - assert "source /home/deploy/.bashrc" in call_args - - -def test_run_command_password_sudo(mock_conn, monkeypatch): - monkeypatch.setattr(config, "USE_SUDO", True) - monkeypatch.setattr(config, "REMOTE_PASSWORD", "mypass") - run_command(mock_conn, "test-cmd") - - call_args = mock_conn.run.call_args[0][0] - assert "printf '%s\\n' 'mypass' | sudo -S" in call_args - - -def test_install_dependencies_missing(mock_conn): - # Mocking 'which' results: git exists, others don't - mock_conn.run.side_effect = [ - Mock(stdout="/usr/bin/git"), # git - Mock(stdout=""), # pip - Mock(stdout=""), # dev - Mock(stdout=""), # build-essential - Mock(stdout=""), # libssl - Mock(stdout=""), # libffi - Mock(ok=True), # apt update - Mock(ok=True), # apt install - ] - install_dependencies(mock_conn) - # 6 'which' calls + 2 installations = 8 calls - assert mock_conn.run.call_count >= 8 diff --git a/tests/test_db_utils.py b/tests/test_db_utils.py deleted file mode 100644 index 32bdc98..0000000 --- a/tests/test_db_utils.py +++ /dev/null @@ -1,77 +0,0 @@ -from unittest.mock import Mock, patch - -import pytest -from fabric import Connection - -from src.config import config -from src.providers import utils - - -@pytest.fixture -def mock_conn(): - conn = Mock(spec=Connection) - conn.run.return_value = Mock(stdout="", ok=True) - conn.cd.return_value.__enter__ = Mock(return_value=None) - conn.cd.return_value.__exit__ = Mock(return_value=None) - return conn - - -def test_detect_db_postgres(mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_SUBDIR", "/app") - - def mock_run(cmd, **kwargs): - if "test -f" in cmd: - if "docker-compose.yml" in cmd: - return Mock(stdout="exists") - return Mock(stdout="not exists") - if "grep" in cmd: - if "postgres" in cmd: - return Mock(stdout="image: postgres") - return Mock(stdout="") - return Mock(stdout="") - - mock_conn.run.side_effect = mock_run - dbs = utils.detect_database_type(mock_conn) - assert "postgres" in dbs - - -def test_detect_db_multiple(mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_SUBDIR", "/app") - - def mock_run(cmd, **kwargs): - if "test -f" in cmd: - if "docker-compose.yml" in cmd: - return Mock(stdout="exists") - return Mock(stdout="not exists") - if "grep" in cmd: - if "postgres" in cmd or "redis" in cmd: - return Mock(stdout="match") - return Mock(stdout="") - return Mock(stdout="") - - mock_conn.run.side_effect = mock_run - dbs = utils.detect_database_type(mock_conn) - assert "postgres" in dbs - assert "redis" in dbs - - -def test_fix_permissions_logic(mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_SUBDIR", "/app") - - def mock_run(cmd, **kwargs): - if "test -f" in cmd: - return Mock(stdout="exists") - if "grep" in cmd: - return Mock(stdout="postgres") - if "find" in cmd: - return Mock(stdout="./pgdata") - return Mock(stdout="") - - mock_conn.run.side_effect = mock_run - - with patch("src.providers.utils.run_command") as mock_run_cmd: - utils.fix_database_permissions(mock_conn) - assert mock_run_cmd.called - args = mock_run_cmd.call_args[0][1] - assert "chown -R 999:999" in args - assert "./pgdata" in args diff --git a/tests/test_env_manager.py b/tests/test_env_manager.py deleted file mode 100644 index 5619dd2..0000000 --- a/tests/test_env_manager.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -from unittest.mock import Mock, patch - -import pytest -from fabric import Connection - -from src.config import config -from src.env_manager import ( - create_env_file, - detect_file_patterns, - determine_file_structure, - generate_env_files, - parse_all_in_one_secret, -) - - -@pytest.fixture -def mock_conn(): - conn = Mock(spec=Connection) - conn.run.return_value = Mock(stdout="", ok=True) - conn.cd.return_value.__enter__ = Mock(return_value=None) - conn.cd.return_value.__exit__ = Mock(return_value=None) - return conn - - -def test_parse_formats(): - assert parse_all_in_one_secret("K1=V1\nK2=V2", "env") == {"K1": "V1", "K2": "V2"} - assert parse_all_in_one_secret('{"K1": "V1"}', "json") == {"K1": "V1"} - assert parse_all_in_one_secret("K1: V1", "yaml") == {"K1": "V1"} - - -def test_pattern_detection(): - # Variable names must end with _ or have more parts to trigger regex correctly - env_vars = {"ENV_APP_PORT": "80", "ENV_DATABASE_URL": "...", "ENV_REDIS_HOST": "..."} - patterns = detect_file_patterns(env_vars, "nested") - assert ".env.app" in patterns - assert ".env.database" in patterns - assert ".env.redis" in patterns - - -def test_structure_nested(monkeypatch): - monkeypatch.setattr(config, "ENV_FILES_PATH", None) - paths = determine_file_structure("nested", [".env.app"], "prod", "/app") - assert paths[".env.app"] == "/app/.envs/prod/.env.app" - - -def test_root_mega_file_creation(mock_conn, monkeypatch): - test_secrets = { - "ENV_APP_V1": "val1", - "ENV_DB_V2": "val2", - "ENV_FILES_GENERATE": "true", - "ENV_FILES_CREATE_ROOT": "true", - "ENV_FILES_STRUCTURE": "flat", - "ENV_FILES_PATTERNS": "", - } - - with patch.dict(os.environ, test_secrets, clear=False): - config.load() - # Force these values and a predictable subdir - config.ENV_FILES_GENERATE = True - config.ENV_FILES_CREATE_ROOT = True - config.ENV_FILES_STRUCTURE = "flat" - config.ENV_FILES_PATTERNS = [] - config.GIT_SUBDIR = "/testing" - - with patch("src.env_manager.create_env_file") as mock_create: - generate_env_files(mock_conn) - - # We expect .env.app, .env.db and root .env - # (plus maybe .env.files based on the shared ENV_ prefix bug) - assert mock_create.call_count >= 3 - - # Look for the root .env in our testing subdir - root_calls = [c for c in mock_create.call_args_list if c[0][1] == "/testing/.env"] - assert len(root_calls) == 1 - merged_vars = root_calls[0][0][2] - assert "V1" in merged_vars - assert "V2" in merged_vars - - -def test_heredoc_escaping(mock_conn): - env_vars = {"KEY": "val$with$dollars"} - create_env_file(mock_conn, ".env", env_vars) - found_secure_cat = False - for call in mock_conn.run.call_args_list: - cmd = call[0][0] - if "cat >" in cmd and "<< 'EOF'" in cmd: - found_secure_cat = True - assert "KEY=val$with$dollars" in cmd - assert found_secure_cat diff --git a/tests/test_errors.py b/tests/test_errors.py deleted file mode 100644 index cf6a2ba..0000000 --- a/tests/test_errors.py +++ /dev/null @@ -1,71 +0,0 @@ -from unittest.mock import Mock, patch - -import pytest -from fabric import Connection - -from src import git_ops, orchestrator, providers -from src.config import config - - -@pytest.fixture -def mock_conn(): - conn = Mock(spec=Connection) - conn.run.return_value = Mock(stdout="", ok=True) - conn.cd.return_value.__enter__ = Mock(return_value=None) - conn.cd.return_value.__exit__ = Mock(return_value=None) - return conn - - -class TestErrorScenarios: - def test_ssh_connection_failure(self, monkeypatch): - monkeypatch.setattr(config, "REMOTE_HOST", "bad-host") - # Set auth method to none to avoid early exit in setup_git_auth - monkeypatch.setattr(config, "GIT_AUTH_METHOD", "none") - with patch("src.orchestrator.Connection", side_effect=Exception("SSH Timeout")): - with patch("src.orchestrator.setup_ssh_key"): - # Use handle_connection to trigger the Connection call - with pytest.raises(Exception, match="SSH Timeout"): - orchestrator.handle_connection() - - def test_git_clone_failure(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_AUTH_METHOD", "none") - monkeypatch.setattr(config, "REMOTE_DIR", "/app") - # dir check -> not exists, then clone -> fails - mock_conn.run.side_effect = [ - Mock(ok=True), # mkdir - Mock(stdout="not exists"), - Exception("Git clone failed"), - ] - with pytest.raises(Exception, match="Git clone failed"): - git_ops.clone_repo(mock_conn) - - def test_baremetal_deploy_script_fail(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_SUBDIR", "/app") - # deploy.sh exists, but run_command (which we'll mock) fails - mock_conn.run.side_effect = [ - Mock(ok=True), # test -f deploy.sh - Mock(ok=True), # chmod +x - ] - with patch("src.providers.baremetal.run_command") as mock_run: - mock_run.return_value = Mock(stdout="Command failed with exit code: 1") - with pytest.raises(ValueError, match="deploy.sh failed with exit code: 1"): - providers.baremetal.deploy_baremetal(mock_conn) - - def test_docker_login_missing_creds(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_USER", None) - with pytest.raises(ValueError, match="GIT_USER and GIT_TOKEN must be set"): - providers.docker.docker_login(mock_conn, registry_type="ghcr") - - def test_missing_k8s_manifests(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_SUBDIR", "/app") - monkeypatch.setattr(config, "K8S_MANIFEST_PATH", None) - # All manifest directory/file checks return False - mock_conn.run.return_value = Mock(ok=False) - - with patch("src.providers.k8s.docker_login"): - with pytest.raises(ValueError, match="No k8s_manifest_path specified"): - providers.k8s.deploy_k8s(mock_conn) - - def test_unsupported_registry(self, mock_conn): - with pytest.raises(ValueError, match="Unsupported registry_type"): - providers.docker.docker_login(mock_conn, registry_type="unknown") diff --git a/tests/test_git_ops.py b/tests/test_git_ops.py deleted file mode 100644 index 324e810..0000000 --- a/tests/test_git_ops.py +++ /dev/null @@ -1,107 +0,0 @@ -from unittest.mock import MagicMock, Mock, patch - -import pytest -from fabric import Connection - -from src import git_ops -from src.config import config - - -@pytest.fixture -def mock_conn(): - conn = MagicMock(spec=Connection) - conn.run.return_value = Mock(stdout="", ok=True) - conn.cd.return_value.__enter__ = Mock(return_value=None) - conn.cd.return_value.__exit__ = Mock(return_value=None) - return conn - - -def test_git_auth_token_setup(monkeypatch): - monkeypatch.setattr(config, "GIT_AUTH_METHOD", "token") - monkeypatch.setattr(config, "GIT_TOKEN", "token123") - monkeypatch.setattr(config, "GIT_USER", "user123") - monkeypatch.setattr(config, "GIT_URL", "https://github.com/org/repo.git") - git_ops.setup_git_auth() - assert config.AUTH_GIT_URL == "https://user123:token123@github.com/org/repo.git" - - -def test_git_auth_ssh_conversion(monkeypatch): - monkeypatch.setattr(config, "GIT_AUTH_METHOD", "ssh") - monkeypatch.setattr(config, "GIT_SSH_KEY", "-----BEGIN PRIVATE KEY-----") - monkeypatch.setattr(config, "GIT_URL", "https://github.com/org/repo.git") - with patch("src.git_ops.tempfile.NamedTemporaryFile") as mock_tmp, patch("os.chmod"): - mock_tmp.return_value.__enter__.return_value.name = "/tmp/key" - git_ops.setup_git_auth() - assert config.AUTH_GIT_URL == "git@github.com:org/repo.git" - - -def test_clone_new_repository(mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_DIR", "/app/repo") - monkeypatch.setattr(config, "REMOTE_DIR", "/app") - monkeypatch.setattr(config, "PROJECT_NAME", "repo") - monkeypatch.setattr(config, "GIT_SUBDIR", "/app/repo") - monkeypatch.setattr(config, "ENVIRONMENT", "main") - - mock_conn.run.side_effect = [ - Mock(ok=True), # mkdir - Mock(stdout="not exists"), # dir check - Mock(ok=True), # git clone - Mock(ok=True), # safe.directory - Mock(ok=True), # chown - Mock(stdout="main"), # rev-parse (current branch) - Mock(ok=True), # fetch & reset - ] - - git_ops.clone_repo(mock_conn) - assert any("git clone" in str(call) for call in mock_conn.run.call_args_list) - - -def test_clone_existing_needs_checkout(mock_conn, monkeypatch): - monkeypatch.setattr(config, "ENVIRONMENT", "staging") - monkeypatch.setattr(config, "GIT_DIR", "/app/repo") - monkeypatch.setattr(config, "GIT_SUBDIR", "/app/repo") - - mock_conn.run.side_effect = [ - Mock(ok=True), # mkdir - Mock(stdout="exists"), # dir exists - Mock(stdout="git_repo"), # is git repo - Mock(ok=True), # safe.directory - Mock(ok=True), # chown - Mock(stdout="main"), # rev-parse -> we are on main, but env is staging - Mock(ok=True), # git stash - Mock(ok=True), # git checkout staging - Mock(ok=True), # fetch & reset - ] - - git_ops.clone_repo(mock_conn) - calls = [str(call) for call in mock_conn.run.call_args_list] - assert any("git checkout staging" in c for c in calls) - assert any("git stash" in c for c in calls) - - -def test_clone_ssh_auth_flow(mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_AUTH_METHOD", "ssh") - monkeypatch.setattr(config, "GIT_SSH_KEY_PATH", "/tmp/key") - monkeypatch.setattr(config, "PROJECT_NAME", "repo") - monkeypatch.setattr(config, "GIT_DIR", "/app/repo") - - mock_conn.run.side_effect = [ - Mock(ok=True), # mkdir - Mock(ok=True), # chmod key - Mock(ok=True), # mkdir .ssh - Mock(ok=True), # echo config - Mock(ok=True), # chmod config - Mock(stdout="not exists"), # dir check - Mock(ok=True), # git clone - Mock(ok=True), # safe.directory - Mock(ok=True), # chown - Mock(stdout="dev"), # rev-parse - Mock(ok=True), # fetch & reset - ] - - git_ops.clone_repo(mock_conn) - # Check if key was put to remote - assert mock_conn.put.called - # Check if GIT_SSH_COMMAND was used - clone_call = [c for c in mock_conn.run.call_args_list if "git clone" in str(c)][0] - assert "GIT_SSH_COMMAND" in clone_call[0][0] diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py deleted file mode 100644 index 06a2066..0000000 --- a/tests/test_orchestrator.py +++ /dev/null @@ -1,77 +0,0 @@ -from unittest.mock import Mock, patch - -import pytest -from fabric import Connection - -from src import orchestrator -from src.config import config - - -@pytest.fixture -def mock_conn_class(): - with patch("src.orchestrator.Connection") as mock: - yield mock - - -@pytest.fixture -def mock_conn(): - conn = Mock(spec=Connection) - conn.run.return_value = Mock(stdout="test-host", ok=True) - conn.cd.return_value.__enter__ = Mock(return_value=None) - conn.cd.return_value.__exit__ = Mock(return_value=None) - return conn - - -def test_handle_connection_flow(mock_conn_class, mock_conn, monkeypatch, tmp_path): - # Setup - monkeypatch.setattr(config, "REMOTE_HOST", "1.2.3.4") - monkeypatch.setattr(config, "ENV_FILES_GENERATE", True) - monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "baremetal") - - github_output = tmp_path / "output" - monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) - - mock_conn_class.return_value = mock_conn - - # Mocking all dependencies - with ( - patch("src.orchestrator.setup_ssh_key"), - patch("src.orchestrator.setup_git_auth"), - patch("src.orchestrator.install_dependencies"), - patch("src.orchestrator.clone_repo"), - patch("src.orchestrator.generate_env_files"), - patch("src.orchestrator.deploy"), - ): - orchestrator.handle_connection() - - # Verify outputs - content = github_output.read_text() - assert "remote_hostname=test-host" in content - assert "deployment_status=success" in content - - -def test_deploy_routing(monkeypatch, mock_conn): - # 1. Test custom deploy command - monkeypatch.setattr(config, "DEPLOY_COMMAND", "echo hello") - with patch("src.orchestrator.run_command") as mock_run: - orchestrator.deploy(mock_conn) - assert "echo hello" in mock_run.call_args[0][1] - - # 2. Test barefoot routing - monkeypatch.setattr(config, "DEPLOY_COMMAND", None) - monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "baremetal") - with patch("src.orchestrator.deploy_baremetal") as mock_bm: - orchestrator.deploy(mock_conn) - mock_bm.assert_called_once() - - # 3. Test docker routing - monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "docker") - with patch("src.orchestrator.deploy_docker") as mock_dk: - orchestrator.deploy(mock_conn) - mock_dk.assert_called_once() - - # 4. Test k8s routing - monkeypatch.setattr(config, "DEPLOYMENT_TYPE", "k8s") - with patch("src.orchestrator.deploy_k8s") as mock_k8s: - orchestrator.deploy(mock_conn) - mock_k8s.assert_called_once() diff --git a/tests/test_providers.py b/tests/test_providers.py deleted file mode 100644 index 24334eb..0000000 --- a/tests/test_providers.py +++ /dev/null @@ -1,96 +0,0 @@ -from unittest.mock import Mock, patch - -import pytest -from fabric import Connection - -from src.config import config -from src.providers import baremetal, docker, k8s - - -@pytest.fixture -def mock_conn(): - conn = Mock(spec=Connection) - conn.run.return_value = Mock(stdout="", ok=True) - conn.cd.return_value.__enter__ = Mock(return_value=None) - conn.cd.return_value.__exit__ = Mock(return_value=None) - return conn - - -class TestBaremetal: - def test_deploy_sh_flow(self, mock_conn): - def mock_run(cmd, **kwargs): - if "test -f deploy.sh" in cmd: - return Mock(ok=True) - return Mock(ok=False) - - mock_conn.run.side_effect = mock_run - - with patch("src.providers.baremetal.run_command") as mock_run_cmd: - baremetal.deploy_baremetal(mock_conn) - assert "./deploy.sh" in mock_run_cmd.call_args[0][1] - - def test_makefile_fallback(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "ENVIRONMENT", "staging") - - # deploy.sh no, Makefile yes - def mock_run(cmd, **kwargs): - if "test -f deploy.sh" in cmd: - return Mock(ok=False) - if "test -f Makefile" in cmd: - return Mock(ok=True) - return Mock(ok=False) - - mock_conn.run.side_effect = mock_run - - with patch("src.providers.baremetal.run_command") as mock_run_cmd: - baremetal.deploy_baremetal(mock_conn) - assert "make staging" in mock_run_cmd.call_args[0][1] - - -class TestDocker: - def test_docker_login_ghcr(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "GIT_USER", "u") - monkeypatch.setattr(config, "GIT_TOKEN", "t") - docker.docker_login(mock_conn, registry_type="ghcr") - assert "docker login ghcr.io" in mock_conn.run.call_args[0][0] - - def test_deploy_docker_with_profile(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "PROFILE", "api") - monkeypatch.setattr(config, "GIT_SUBDIR", "/app") - with patch("src.providers.docker.docker_login"), patch( - "src.providers.docker.run_command" - ) as mock_run: - docker.deploy_docker(mock_conn) - assert "--profile api" in mock_run.call_args[0][1] - - -class TestK8s: - def test_k3s_installation(self, mock_conn): - mock_conn.run.side_effect = [ - Mock(stdout=""), # which k3s -> not found - Mock(ok=True), # curl k3s sh - Mock(ok=True), # systemctl enable - Mock(ok=True), # systemctl start - Mock(ok=True), # echo KUBECONFIG - ] - with patch("src.providers.k8s.run_command"): - k8s.install_k3s(mock_conn) - assert mock_conn.run.call_count >= 3 - - def test_k8s_deploy_namespace_creation(self, mock_conn, monkeypatch): - monkeypatch.setattr(config, "K8S_NAMESPACE", "custom-ns") - monkeypatch.setattr(config, "K8S_MANIFEST_PATH", "k8s/") - monkeypatch.setattr(config, "GIT_SUBDIR", "/app") - - mock_conn.run.side_effect = [ - Mock(ok=True), # test -d k8s - Mock(ok=True), # create namespace - Mock(ok=True), # apply -f k8s/ - ] - - with patch("src.providers.k8s.docker_login"): - k8s.deploy_k8s(mock_conn) - - calls = [str(call) for call in mock_conn.run.call_args_list] - assert any("kubectl create namespace custom-ns" in c for c in calls) - assert any("kubectl apply -f k8s/" in c for c in calls) From 756a9d86b61cf3378c86731168a18f38de709033 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 14:57:12 +0100 Subject: [PATCH 10/13] feat: add artifact copying feature to efficiently copy local build artifacts to the server --- README.md | 17 ++++++ action.yml | 4 ++ changelogs/2026-01-29_14-34-57.md | 2 +- changelogs/2026-01-29_14-56-07.md | 26 ++++++++++ coverage.xml | 60 +++++++++++++++++----- src/config.py | 9 ++++ src/connection.py | 58 +++++++++++++++++++++ src/orchestrator.py | 4 +- tests/integration/test_integration_full.py | 36 +++++++++++++ tests/unit/test_artifacts.py | 53 +++++++++++++++++++ 10 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 changelogs/2026-01-29_14-56-07.md create mode 100644 tests/unit/test_artifacts.py diff --git a/README.md b/README.md index da3ead1..f7e7d7d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A comprehensive GitHub Action for deploying applications to baremetal servers vi - 📝 **Environment File Generation** - Automatically create `.env` files from GitHub secrets and variables - 🔒 **All-in-One Secret Support** - Store multiple variables in single secrets with multiple formats (ENV, JSON, YAML) - 🏗️ **Flexible File Structures** - Support single, flat, nested, auto, and custom file organization +- 📦 **Artifact Copying** - Efficiently copy local build artifacts (dist/, node_modules) to the server with auto-compression - 🎛️ **Priority System** - Environment-specific secrets override base secrets automatically - 🏗️ **Jenkins Compatible** - Fully compatible with Jenkins via pre-built GHCR image and Jenkinsfile @@ -78,7 +79,23 @@ metaldeploy --host 1.2.3.4 --user root --ssh-key ~/.ssh/id_rsa --type docker environment: prod registry_type: dockerhub registry_username: ${{ secrets.DOCKERHUB_USERNAME }} + registry_username: ${{ secrets.DOCKERHUB_USERNAME }} registry_password: ${{ secrets.DOCKERHUB_PASSWORD }} + +### Copying Build Artifacts + +Copy specific files or directories (like `node_modules` or `dist/`) to the server. The action automatically compresses them for fast transfer. + +```yaml +- name: Deploy with Artifacts + uses: OpsGuild/MetalDeploy@v1 + with: + remote_host: ${{ secrets.REMOTE_HOST }} + ssh_key: ${{ secrets.SSH_PRIVATE_KEY }} + # Copy local 'dist' folder to remote '/app/dist' + # Copy local 'package.json' to remote '/app/package.json' + copy_artifacts: "dist/:/app/dist, package.json:/app/package.json" +``` ``` ### For Jenkins diff --git a/action.yml b/action.yml index ebf2077..f846077 100644 --- a/action.yml +++ b/action.yml @@ -100,6 +100,9 @@ inputs: description: 'Format for parsing all-in-one secrets: auto, env, json, yaml' required: false default: 'auto' + copy_artifacts: + description: 'Comma-separated list of build artifacts to copy to the server (local_path:remote_path). Paths are relative to git_dir unless absolute.' + required: false outputs: deployment_status: description: 'Deployment status (success/failed)' @@ -198,3 +201,4 @@ runs: ENV_FILES_PATTERNS: ${{ inputs.env_files_patterns }} ENV_FILES_CREATE_ROOT: ${{ inputs.env_files_create_root }} ENV_FILES_FORMAT: ${{ inputs.env_files_format }} + COPY_ARTIFACTS: ${{ inputs.copy_artifacts }} diff --git a/changelogs/2026-01-29_14-34-57.md b/changelogs/2026-01-29_14-34-57.md index bc1fd3a..0f013d1 100644 --- a/changelogs/2026-01-29_14-34-57.md +++ b/changelogs/2026-01-29_14-34-57.md @@ -6,4 +6,4 @@ - Exclusion of `generated_envs` directories from version control and pre-commit checks to prevent unnecessary checks and commits. ### Changed -- Updated pre-commit configuration to exclude files within `tests/` directory that are in `generated_envs/` subdirectories, improving pre-commit hook performance. \ No newline at end of file +- Updated pre-commit configuration to exclude files within `tests/` directory that are in `generated_envs/` subdirectories, improving pre-commit hook performance. diff --git a/changelogs/2026-01-29_14-56-07.md b/changelogs/2026-01-29_14-56-07.md new file mode 100644 index 0000000..7bc3b2a --- /dev/null +++ b/changelogs/2026-01-29_14-56-07.md @@ -0,0 +1,26 @@ +# Changelog + +## [Unreleased] + +### Added +- **Artifact Copying**: Efficiently copy local build artifacts (dist/, node_modules) to the server with auto-compression. This feature allows users to transfer specific files or directories to the server, making it easier to manage and deploy applications. + +### Changed +- Updated the `README.md` to reflect the new artifact copying feature and provide usage examples. +- Modified the `action.yml` to include the `copy_artifacts` input, allowing users to specify the artifacts to be copied. +- Updated the `config.py` to parse the `COPY_ARTIFACTS` environment variable and store the artifact paths. +- Modified the `connection.py` to include the `copy_artifacts` function, which handles the copying of artifacts to the remote server. +- Updated the `orchestrator.py` to call the `copy_artifacts` function and copy the artifacts before deploying the application. +- Added integration tests for the artifact copying feature in `tests/integration/test_integration_full.py`. + +### Fixed +- No bug fixes in this release. + +### Removed +- No features removed in this release. + +### Deprecated +- No features deprecated in this release. + +### Security +- No security fixes in this release. \ No newline at end of file diff --git a/coverage.xml b/coverage.xml index 675da1e..fead5a8 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /workspace/personal/MetalDeploy/src - + @@ -14,7 +14,7 @@ - + @@ -67,10 +67,17 @@ - + + + + + + + + - + @@ -93,7 +100,7 @@ - + @@ -117,6 +124,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -458,7 +493,7 @@ - + @@ -518,14 +553,15 @@ - - - - - + + + + + + diff --git a/src/config.py b/src/config.py index c4a43b3..6d07e11 100644 --- a/src/config.py +++ b/src/config.py @@ -67,6 +67,15 @@ def get_env(name, default=None): self.ENV_FILES_CREATE_ROOT = get_bool_env("ENV_FILES_CREATE_ROOT", "false") self.ENV_FILES_FORMAT = os.getenv("ENV_FILES_FORMAT", "auto").lower() + # Build Artifacts + artifacts = get_env("COPY_ARTIFACTS") + self.COPY_ARTIFACTS = [] + if artifacts: + for item in artifacts.split(","): + if ":" in item: + local, remote = item.split(":", 1) + self.COPY_ARTIFACTS.append((local.strip(), remote.strip())) + # Global state for temporary files self.SSH_KEY_PATH = None self.GIT_SSH_KEY_PATH = None diff --git a/src/connection.py b/src/connection.py index d042de4..a2ce865 100644 --- a/src/connection.py +++ b/src/connection.py @@ -89,3 +89,61 @@ def install_dependencies(conn): print("======= Dependencies installed =======") else: print("======= All dependencies already installed =======") + + +def copy_artifacts(conn): + """ + Copy build artifacts from local to remote using compression. + Format: local_path:remote_path + """ + if not config.COPY_ARTIFACTS: + return + + print(f"======= Copying {len(config.COPY_ARTIFACTS)} artifacts =======") + import tarfile + + for local_path, remote_path in config.COPY_ARTIFACTS: + # Resolve remote path + if not remote_path.startswith("/"): + remote_path = os.path.join(config.GIT_DIR, remote_path) + + # Check if local path exists + if not os.path.exists(local_path): + print(f"⚠️ Warning: Local artifact '{local_path}' not found, skipping.") + continue + + print(f"📦 Processing: {local_path} -> {remote_path}") + + # Create a temporary tarball + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp_tar: + tmp_tar_path = tmp_tar.name + + try: + # Compress + with tarfile.open(tmp_tar_path, "w:gz") as tar: + arcname = os.path.basename(remote_path) + tar.add(local_path, arcname=arcname) + + # Upload + remote_tmp = f"/tmp/{os.path.basename(tmp_tar_path)}" + conn.put(tmp_tar_path, remote_tmp) + + # Ensure parent of destination exists + remote_parent = os.path.dirname(remote_path) + # Create parent if needed + run_command(conn, f"mkdir -p {remote_parent}") + + # Remove existing target to be safe (overwrite) + run_command(conn, f"rm -rf {remote_path}") + + run_command(conn, f"tar -xzf {remote_tmp} -C {remote_parent}") + + # Cleanup remote tmp + run_command(conn, f"rm {remote_tmp}") + + finally: + # Cleanup local tmp + if os.path.exists(tmp_tar_path): + os.unlink(tmp_tar_path) + + print("======= Artifacts copied =======") diff --git a/src/orchestrator.py b/src/orchestrator.py index 237f8b6..ab9d5c1 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -3,7 +3,7 @@ from fabric import Connection from src import config -from src.connection import install_dependencies, run_command, setup_ssh_key +from src.connection import copy_artifacts, install_dependencies, run_command, setup_ssh_key from src.env_manager import generate_env_files from src.git_ops import clone_repo, setup_git_auth from src.providers.baremetal import deploy_baremetal @@ -75,6 +75,8 @@ def handle_connection(): if config.ENV_FILES_GENERATE: generate_env_files(conn) + copy_artifacts(conn) + deploy(conn) if os.getenv("GITHUB_OUTPUT"): diff --git a/tests/integration/test_integration_full.py b/tests/integration/test_integration_full.py index 585b3eb..3306ae8 100644 --- a/tests/integration/test_integration_full.py +++ b/tests/integration/test_integration_full.py @@ -38,6 +38,42 @@ def test_ssh_connection(integration_conn): assert result.stdout.strip() == "root" +@pytest.mark.integration +def test_artifact_copying(integration_conn, clean_remote_dir, monkeypatch, tmp_path): + """Test copying local artifacts to remote.""" + # 1. Setup local artifact + local_dir = tmp_path / "dist" + local_dir.mkdir() + (local_dir / "app.js").write_text("console.log('hello');") + + # 2. Setup config + remote_base = clean_remote_dir("artifact_test") + # Clean remote dir creates the dir, but our logic also mkdir -p parent. + # We want to put 'dist' inside 'remote_base' + remote_target = f"{remote_base}/app_dist" + + monkeypatch.setattr(config, "COPY_ARTIFACTS", [(str(local_dir), remote_target)]) + monkeypatch.setattr(config, "GIT_DIR", remote_base) + + # 3. Trigger Copy + from src.connection import copy_artifacts + + copy_artifacts(integration_conn) + + # 4. Verify + # Since we copied directory 'dist' to 'remote_target' (app_dist) + # The layout depends on how we tarred it. + # Logic: tar.add(local_dir, arcname=basename(remote_target)) -> app_dist/... + # Extract into parent of remote_target (remote_base) -> remote_base/app_dist + + result = integration_conn.run(f"ls -R {remote_target}", warn=True) + assert result.ok + assert "app.js" in result.stdout + + content = integration_conn.run(f"cat {remote_target}/app.js", hide=True).stdout + assert "console.log('hello');" in content + + # ------------------------------------------------------------------------------ # HYPER-EXHAUSTIVE PERMUTATION TESTS # ------------------------------------------------------------------------------ diff --git a/tests/unit/test_artifacts.py b/tests/unit/test_artifacts.py new file mode 100644 index 0000000..d62568c --- /dev/null +++ b/tests/unit/test_artifacts.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, patch +import os +import tarfile +import pytest +from src import config +from src.connection import copy_artifacts + +@pytest.fixture +def mock_conn(): + conn = MagicMock() + conn.run.return_value = MagicMock(ok=True, stdout="") + return conn + +def test_copy_artifacts_no_config(mock_conn, monkeypatch): + monkeypatch.setattr(config, "COPY_ARTIFACTS", []) + copy_artifacts(mock_conn) + mock_conn.put.assert_not_called() + +def test_copy_artifacts_local_missing(mock_conn, monkeypatch, capsys): + monkeypatch.setattr(config, "COPY_ARTIFACTS", [("missing_local", "/remote")]) + copy_artifacts(mock_conn) + mock_conn.put.assert_not_called() + assert "not found, skipping" in capsys.readouterr().out + +def test_copy_artifacts_success(mock_conn, monkeypatch, tmp_path): + # Create a dummy local file + local_file = tmp_path / "test.txt" + local_file.write_text("content") + + # Configure artifacts + monkeypatch.setattr(config, "COPY_ARTIFACTS", [(str(local_file), "/app/test.txt")]) + monkeypatch.setattr(config, "GIT_DIR", "/app") + + with patch("tarfile.open") as mock_tar_open, \ + patch("src.connection.run_command") as mock_run_cmd: + + mock_tar = MagicMock() + mock_tar_open.return_value.__enter__.return_value = mock_tar + + copy_artifacts(mock_conn) + + # Verify tarball creation + mock_tar.add.assert_called_with(str(local_file), arcname="test.txt") + + # Verify upload + assert mock_conn.put.called + + # Verify extraction logic runs + calls = [str(c) for c in mock_run_cmd.mock_calls] + assert any("mkdir -p /app" in c for c in calls) + assert any("rm -rf /app/test.txt" in c for c in calls) + assert any("tar -xzf" in c and "-C /app" in c for c in calls) + assert any("rm /tmp/" in c for c in calls) From 9dac7cda94707e620bc030f302bd63c3f466419f Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 15:38:05 +0100 Subject: [PATCH 11/13] feat: add multi-server deployment support with smart variable distribution --- .flake8 | 2 +- .gitignore | 2 + Makefile | 4 + README.md | 20 +++ action.yml | 8 +- changelogs/2026-01-29_14-56-07.md | 10 +- changelogs/2026-01-29_15-37-50.md | 21 +++ coverage.xml | 139 ++++++++++++------ pyproject.toml | 2 + src/orchestrator.py | 72 ++++++++- .../auto_dev/.envs/dev/.env.api | 1 - .../auto_dev/.envs/dev/.env.app | 3 - .../auto_dev/.envs/dev/.env.database | 2 - .../auto_dev/.envs/dev/.env.elastic | 1 - .../auto_dev/.envs/dev/.env.kafka | 1 - .../auto_dev/.envs/dev/.env.minio | 1 - .../auto_dev/.envs/dev/.env.redis | 2 - .../generated_envs/auto_dev/.envs/dev/.env.s3 | 1 - tests/generated_envs/create_root_agg/.env | 12 -- .../create_root_agg/.envs/dev/.env.api | 1 - .../create_root_agg/.envs/dev/.env.app | 3 - .../create_root_agg/.envs/dev/.env.database | 2 - .../create_root_agg/.envs/dev/.env.elastic | 1 - .../create_root_agg/.envs/dev/.env.kafka | 1 - .../create_root_agg/.envs/dev/.env.minio | 1 - .../create_root_agg/.envs/dev/.env.redis | 2 - .../create_root_agg/.envs/dev/.env.s3 | 1 - tests/generated_envs/custom_path_abs/.env.api | 1 - tests/generated_envs/custom_path_abs/.env.app | 3 - .../custom_path_abs/.env.database | 2 - .../custom_path_abs/.env.elastic | 1 - .../generated_envs/custom_path_abs/.env.kafka | 1 - .../generated_envs/custom_path_abs/.env.minio | 1 - .../generated_envs/custom_path_abs/.env.redis | 2 - tests/generated_envs/custom_path_abs/.env.s3 | 1 - .../custom_path_rel/my_configs/.env.api | 1 - .../custom_path_rel/my_configs/.env.app | 3 - .../custom_path_rel/my_configs/.env.database | 2 - .../custom_path_rel/my_configs/.env.elastic | 1 - .../custom_path_rel/my_configs/.env.kafka | 1 - .../custom_path_rel/my_configs/.env.minio | 1 - .../custom_path_rel/my_configs/.env.redis | 2 - .../custom_path_rel/my_configs/.env.s3 | 1 - .../explicit_patterns/.env.only_app | 1 - .../explicit_patterns/.env.only_db | 1 - .../generated_envs/file_path_secret/.env.api | 1 - .../generated_envs/file_path_secret/.env.app | 3 - .../file_path_secret/.env.database | 2 - .../file_path_secret/.env.elastic | 1 - .../file_path_secret/.env.kafka | 1 - .../file_path_secret/.env.minio | 1 - .../file_path_secret/.env.redis | 2 - tests/generated_envs/file_path_secret/.env.s3 | 2 - tests/generated_envs/flat_staging/.env.api | 1 - tests/generated_envs/flat_staging/.env.app | 3 - .../generated_envs/flat_staging/.env.database | 2 - .../generated_envs/flat_staging/.env.elastic | 1 - tests/generated_envs/flat_staging/.env.kafka | 1 - tests/generated_envs/flat_staging/.env.minio | 1 - tests/generated_envs/flat_staging/.env.redis | 2 - tests/generated_envs/flat_staging/.env.s3 | 1 - tests/generated_envs/formats_parsing/.env.api | 1 - tests/generated_envs/formats_parsing/.env.app | 4 - .../formats_parsing/.env.database | 2 - .../formats_parsing/.env.elastic | 1 - .../generated_envs/formats_parsing/.env.kafka | 1 - .../generated_envs/formats_parsing/.env.minio | 1 - .../generated_envs/formats_parsing/.env.redis | 2 - tests/generated_envs/formats_parsing/.env.s3 | 1 - .../multi_env_nested/.envs/dev/.env.api | 1 - .../multi_env_nested/.envs/dev/.env.app | 3 - .../multi_env_nested/.envs/dev/.env.database | 2 - .../multi_env_nested/.envs/dev/.env.elastic | 1 - .../multi_env_nested/.envs/dev/.env.kafka | 1 - .../multi_env_nested/.envs/dev/.env.minio | 1 - .../multi_env_nested/.envs/dev/.env.redis | 2 - .../multi_env_nested/.envs/dev/.env.s3 | 1 - .../multi_env_nested/.envs/prod/.env.api | 1 - .../multi_env_nested/.envs/prod/.env.app | 4 - .../multi_env_nested/.envs/prod/.env.database | 2 - .../multi_env_nested/.envs/prod/.env.elastic | 1 - .../multi_env_nested/.envs/prod/.env.kafka | 1 - .../multi_env_nested/.envs/prod/.env.minio | 1 - .../multi_env_nested/.envs/prod/.env.redis | 2 - .../multi_env_nested/.envs/prod/.env.s3 | 1 - .../multi_env_nested/.envs/staging/.env.api | 1 - .../multi_env_nested/.envs/staging/.env.app | 3 - .../.envs/staging/.env.database | 2 - .../.envs/staging/.env.elastic | 1 - .../multi_env_nested/.envs/staging/.env.kafka | 1 - .../multi_env_nested/.envs/staging/.env.minio | 1 - .../multi_env_nested/.envs/staging/.env.redis | 2 - .../multi_env_nested/.envs/staging/.env.s3 | 1 - tests/generated_envs/single_prod/.env | 13 -- tests/integration/conftest.py | 55 ++++--- tests/integration/docker-compose.yml | 11 ++ tests/integration/test_integration_full.py | 51 +++++++ tests/unit/test_artifacts.py | 26 ++-- tests/unit/test_multi_server.py | 99 +++++++++++++ 99 files changed, 428 insertions(+), 244 deletions(-) create mode 100644 changelogs/2026-01-29_15-37-50.md delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.api delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.app delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.database delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.elastic delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.kafka delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.minio delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.redis delete mode 100644 tests/generated_envs/auto_dev/.envs/dev/.env.s3 delete mode 100644 tests/generated_envs/create_root_agg/.env delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.api delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.app delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.database delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.elastic delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.kafka delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.minio delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.redis delete mode 100644 tests/generated_envs/create_root_agg/.envs/dev/.env.s3 delete mode 100644 tests/generated_envs/custom_path_abs/.env.api delete mode 100644 tests/generated_envs/custom_path_abs/.env.app delete mode 100644 tests/generated_envs/custom_path_abs/.env.database delete mode 100644 tests/generated_envs/custom_path_abs/.env.elastic delete mode 100644 tests/generated_envs/custom_path_abs/.env.kafka delete mode 100644 tests/generated_envs/custom_path_abs/.env.minio delete mode 100644 tests/generated_envs/custom_path_abs/.env.redis delete mode 100644 tests/generated_envs/custom_path_abs/.env.s3 delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.api delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.app delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.database delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.elastic delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.kafka delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.minio delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.redis delete mode 100644 tests/generated_envs/custom_path_rel/my_configs/.env.s3 delete mode 100644 tests/generated_envs/explicit_patterns/.env.only_app delete mode 100644 tests/generated_envs/explicit_patterns/.env.only_db delete mode 100644 tests/generated_envs/file_path_secret/.env.api delete mode 100644 tests/generated_envs/file_path_secret/.env.app delete mode 100644 tests/generated_envs/file_path_secret/.env.database delete mode 100644 tests/generated_envs/file_path_secret/.env.elastic delete mode 100644 tests/generated_envs/file_path_secret/.env.kafka delete mode 100644 tests/generated_envs/file_path_secret/.env.minio delete mode 100644 tests/generated_envs/file_path_secret/.env.redis delete mode 100644 tests/generated_envs/file_path_secret/.env.s3 delete mode 100644 tests/generated_envs/flat_staging/.env.api delete mode 100644 tests/generated_envs/flat_staging/.env.app delete mode 100644 tests/generated_envs/flat_staging/.env.database delete mode 100644 tests/generated_envs/flat_staging/.env.elastic delete mode 100644 tests/generated_envs/flat_staging/.env.kafka delete mode 100644 tests/generated_envs/flat_staging/.env.minio delete mode 100644 tests/generated_envs/flat_staging/.env.redis delete mode 100644 tests/generated_envs/flat_staging/.env.s3 delete mode 100644 tests/generated_envs/formats_parsing/.env.api delete mode 100644 tests/generated_envs/formats_parsing/.env.app delete mode 100644 tests/generated_envs/formats_parsing/.env.database delete mode 100644 tests/generated_envs/formats_parsing/.env.elastic delete mode 100644 tests/generated_envs/formats_parsing/.env.kafka delete mode 100644 tests/generated_envs/formats_parsing/.env.minio delete mode 100644 tests/generated_envs/formats_parsing/.env.redis delete mode 100644 tests/generated_envs/formats_parsing/.env.s3 delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.api delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.app delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.database delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.minio delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.redis delete mode 100644 tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.api delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.app delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.database delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.minio delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.redis delete mode 100644 tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.api delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.app delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.database delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.minio delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.redis delete mode 100644 tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 delete mode 100644 tests/generated_envs/single_prod/.env create mode 100644 tests/unit/test_multi_server.py diff --git a/.flake8 b/.flake8 index 4d3122c..3bcf39f 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] max-line-length = 100 ignore = E501, W503, E203 -exclude = .git, __pycache__, *.pyc, .pytest_cache, .venv, venv +exclude = .git, __pycache__, *.pyc, .pytest_cache, .venv, venv, generated_envs, generated_envs_2, tests/generated_envs, tests/generated_envs_2, tests/integration/generated_envs, tests/integration/generated_envs_2 diff --git a/.gitignore b/.gitignore index e8d1fc8..1a75d68 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,9 @@ htmlcov/ .tox/ .hypothesis/ generated_envs/ +generated_envs_2/ **/generated_envs/ +**/generated_envs_2/ # IDEs .vscode/ diff --git a/Makefile b/Makefile index ef89f91..676b3cb 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,10 @@ clean: rm -rf __pycache__/ find . -type d -name __pycache__ -exec rm -r {} + find . -type f -name "*.pyc" -delete + rm -rf tests/generated_envs + rm -rf tests/generated_envs_2 + rm -rf tests/integration/generated_envs + rm -rf tests/integration/generated_envs_2 # Validate action.yml validate: diff --git a/README.md b/README.md index f7e7d7d..28f4265 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A comprehensive GitHub Action for deploying applications to baremetal servers vi - 🔒 **All-in-One Secret Support** - Store multiple variables in single secrets with multiple formats (ENV, JSON, YAML) - 🏗️ **Flexible File Structures** - Support single, flat, nested, auto, and custom file organization - 📦 **Artifact Copying** - Efficiently copy local build artifacts (dist/, node_modules) to the server with auto-compression +- 🚀 **Multi-Server Deployment** - Deploy to multiple servers concurrently with smart variable distribution - 🎛️ **Priority System** - Environment-specific secrets override base secrets automatically - 🏗️ **Jenkins Compatible** - Fully compatible with Jenkins via pre-built GHCR image and Jenkinsfile @@ -96,6 +97,25 @@ Copy specific files or directories (like `node_modules` or `dist/`) to the serve # Copy local 'package.json' to remote '/app/package.json' copy_artifacts: "dist/:/app/dist, package.json:/app/package.json" ``` + +### Multi-Server Deployment + +Deploy to multiple servers concurrently by providing comma-separated lists for host configuration. If variable lists are shorter than `remote_host`, values are reused/distributed smartly. + +- `remote_host`: `server1,server2,server3` +- `remote_user`: `user1,user2` (user1->server1, user2->server2, user2->server3) +- `ssh_key`: `key1` (key1 used for all servers) + +```yaml +- name: Deploy to Cluster + uses: OpsGuild/MetalDeploy@v1 + with: + remote_host: "10.0.1.1, 10.0.1.2, 10.0.1.3" + remote_user: "admin" # Used for all hosts + ssh_key: ${{ secrets.CLUSTER_SSH_KEY }} + deployment_type: docker + environment: prod +``` ``` ### For Jenkins diff --git a/action.yml b/action.yml index f846077..3d6252f 100644 --- a/action.yml +++ b/action.yml @@ -29,20 +29,20 @@ inputs: required: false default: 'dev' remote_user: - description: 'SSH remote user' + description: 'SSH remote user (comma-separated if multiple hosts, supports reuse/distribution)' required: false default: 'root' remote_host: - description: 'SSH remote host IP or domain' + description: 'SSH remote host IP or domain (comma-separated for multiple hosts)' required: true remote_dir: description: 'Remote directory path for deployment' required: false ssh_key: - description: 'SSH private key for authentication (base64 encoded or raw)' + description: 'SSH private key for authentication (base64 encoded or raw, comma-separated if multiple keys needed)' required: false remote_password: - description: 'SSH password for authentication (if not using SSH key)' + description: 'SSH password for authentication (if not using SSH key, comma-separated if multiple passwords needed)' required: false registry_type: description: 'Docker registry type (ghcr, dockerhub, ecr)' diff --git a/changelogs/2026-01-29_14-56-07.md b/changelogs/2026-01-29_14-56-07.md index 7bc3b2a..1cae5c0 100644 --- a/changelogs/2026-01-29_14-56-07.md +++ b/changelogs/2026-01-29_14-56-07.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Added -- **Artifact Copying**: Efficiently copy local build artifacts (dist/, node_modules) to the server with auto-compression. This feature allows users to transfer specific files or directories to the server, making it easier to manage and deploy applications. +- **Artifact Copying**: Efficiently copy local build artifacts (dist/, node_modules) to the server with auto-compression. This feature allows users to transfer specific files or directories to the server, making it easier to manage and deploy applications. ### Changed - Updated the `README.md` to reflect the new artifact copying feature and provide usage examples. @@ -14,13 +14,13 @@ - Added integration tests for the artifact copying feature in `tests/integration/test_integration_full.py`. ### Fixed -- No bug fixes in this release. +- No bug fixes in this release. ### Removed -- No features removed in this release. +- No features removed in this release. ### Deprecated -- No features deprecated in this release. +- No features deprecated in this release. ### Security -- No security fixes in this release. \ No newline at end of file +- No security fixes in this release. diff --git a/changelogs/2026-01-29_15-37-50.md b/changelogs/2026-01-29_15-37-50.md new file mode 100644 index 0000000..087ecba --- /dev/null +++ b/changelogs/2026-01-29_15-37-50.md @@ -0,0 +1,21 @@ +# Changelog + +## [Unreleased] + +### Added +- **Multi-Server Deployment**: Deploy to multiple servers concurrently with smart variable distribution. This feature allows users to provide comma-separated lists for host configuration, and values are reused or distributed smartly if lists are shorter than the number of hosts. + +### Changed +- Updated `action.yml` to support multi-host deployment by allowing comma-separated values for `remote_user`, `remote_host`, `ssh_key`, and `remote_password`. +- Updated `README.md` to include documentation and examples for multi-server deployment. +- Updated `orchestrator.py` to handle multi-host deployment using multiprocessing for parallel execution. +- Updated `tests/integration/conftest.py` and `tests/integration/docker-compose.yml` to support testing multi-host scenarios. +- Updated `tests/integration/test_integration_full.py` to include a test for multi-host execution. + +### Removed +- Removed numerous test environment files under `tests/generated_envs/` as they are no longer needed or have been replaced. + +### Fixed +- No specific bug fixes are mentioned in the provided diff, but the updates to support multi-server deployment may implicitly address previous limitations or issues related to single-host deployments. + +Note: The changelog focuses on changes from a user/developer perspective, highlighting new features, changes to existing functionality, and removals. Since the provided diff does not explicitly mention bug fixes or security updates, those sections are not included unless directly inferred from the changes (e.g., implicit fixes through new feature implementation). \ No newline at end of file diff --git a/coverage.xml b/coverage.xml index fead5a8..1db0a5f 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /workspace/personal/MetalDeploy/src - + @@ -140,18 +140,18 @@ - - + + + + + + + - - - - - - - - - + + + + @@ -493,75 +493,118 @@ - + + - + - - - - + - - - - - - + + - - - - - - - - + + + + + - + + + + - - - - + + + - - - - - + - - + + - - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 2bcc7c2..df92d93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,12 @@ markers = [ [tool.black] line-length = 100 target-version = ['py39', 'py310', 'py311', 'py312'] +extend-exclude = 'generated_envs|generated_envs_2' [tool.isort] profile = "black" line_length = 100 +skip = ["generated_envs", "generated_envs_2"] [tool.coverage.run] source = ["src", "main.py"] diff --git a/src/orchestrator.py b/src/orchestrator.py index ab9d5c1..6a081d7 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -1,3 +1,5 @@ +import concurrent.futures +import multiprocessing import os from fabric import Connection @@ -40,8 +42,8 @@ def deploy(conn): raise ValueError(f"Invalid deployment_type: {config.DEPLOYMENT_TYPE}") -def handle_connection(): - """Handle the SSH connection and orchestrate the deployment""" +def deploy_single_host(): + """Logic for deploying to a single host (run inside worker or directly)""" setup_ssh_key() setup_git_auth() conn_kwargs = {"host": config.REMOTE_HOST, "user": config.REMOTE_USER} @@ -90,3 +92,69 @@ def handle_connection(): os.unlink(path) except OSError: pass + + +def deploy_worker(overrides): + """Worker entry point for multiprocessing""" + try: + # Reload config with this worker's specific overrides + config.load(overrides) + deploy_single_host() + except Exception as e: + # Ensure the exception is pickleable by stripping complex objects + # Fabric/Invoke exceptions might contain locks or sockets + raise RuntimeError(f"Worker failed: {str(e)}") from None + + +def handle_connection(): + """Handle the SSH connection and orchestrate the deployment (supports multi-host)""" + hosts = [h.strip() for h in config.REMOTE_HOST.split(",") if h.strip()] + + if len(hosts) <= 1: + return deploy_single_host() + + print(f"🚀 Detected {len(hosts)} target hosts: {hosts}") + + def parse_list(val): + return [v.strip() for v in (val or "").split(",") if v.strip()] + + users = parse_list(config.REMOTE_USER) + passwords = parse_list(config.REMOTE_PASSWORD) + keys = parse_list(config.SSH_KEY) + + # Prepare configuration overrides for each host + deployment_configs = [] + + def get_val(lst, index, default=None): + if not lst: + return default + if index < len(lst): + return lst[index] + return lst[-1] # Reuse last value if list is shorter than hosts + + for i, host in enumerate(hosts): + overrides = { + "REMOTE_HOST": host, + "REMOTE_USER": get_val(users, i, config.REMOTE_USER), + "REMOTE_PASSWORD": get_val(passwords, i, config.REMOTE_PASSWORD), + "SSH_KEY": get_val(keys, i, config.SSH_KEY), + } + overrides = {k: v for k, v in overrides.items() if v is not None} + deployment_configs.append(overrides) + + # Run in parallel + ctx = multiprocessing.get_context("spawn") + + with concurrent.futures.ProcessPoolExecutor(mp_context=ctx) as executor: + futures = { + executor.submit(deploy_worker, cfg): cfg["REMOTE_HOST"] for cfg in deployment_configs + } + + for future in concurrent.futures.as_completed(futures): + host = futures[future] + try: + future.result() + print(f"✅ Deployment to {host} succeeded") + except Exception as e: + print(f"❌ Deployment to {host} failed: {e}") + raise e diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.api b/tests/generated_envs/auto_dev/.envs/dev/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.app b/tests/generated_envs/auto_dev/.envs/dev/.env.app deleted file mode 100644 index f853147..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=8000 -DEBUG=true diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.database b/tests/generated_envs/auto_dev/.envs/dev/.env.database deleted file mode 100644 index 66353c8..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=json-admin diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.elastic b/tests/generated_envs/auto_dev/.envs/dev/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.kafka b/tests/generated_envs/auto_dev/.envs/dev/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.minio b/tests/generated_envs/auto_dev/.envs/dev/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.redis b/tests/generated_envs/auto_dev/.envs/dev/.env.redis deleted file mode 100644 index da5493d..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-yaml-master -PORT=6379 diff --git a/tests/generated_envs/auto_dev/.envs/dev/.env.s3 b/tests/generated_envs/auto_dev/.envs/dev/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/auto_dev/.envs/dev/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/create_root_agg/.env b/tests/generated_envs/create_root_agg/.env deleted file mode 100644 index 8ad434d..0000000 --- a/tests/generated_envs/create_root_agg/.env +++ /dev/null @@ -1,12 +0,0 @@ -APP_BASE_URL=https://app.com -APP_PORT=8000 -APP_DEBUG=true -DATABASE_DB_URL=postgres://db:5432 -DATABASE_DB_USER=json-admin -REDIS_HOST=redis-yaml-master -REDIS_PORT=6379 -MINIO_ENDPOINT=http://minio:9000 -S3_BUCKET=my-assets -KAFKA_TOPIC=events-main -ELASTIC_URL=http://elastic:9200 -API_KEY=api-key-12345 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.api b/tests/generated_envs/create_root_agg/.envs/dev/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.app b/tests/generated_envs/create_root_agg/.envs/dev/.env.app deleted file mode 100644 index f853147..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=8000 -DEBUG=true diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.database b/tests/generated_envs/create_root_agg/.envs/dev/.env.database deleted file mode 100644 index 66353c8..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=json-admin diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.elastic b/tests/generated_envs/create_root_agg/.envs/dev/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.kafka b/tests/generated_envs/create_root_agg/.envs/dev/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.minio b/tests/generated_envs/create_root_agg/.envs/dev/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.redis b/tests/generated_envs/create_root_agg/.envs/dev/.env.redis deleted file mode 100644 index da5493d..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-yaml-master -PORT=6379 diff --git a/tests/generated_envs/create_root_agg/.envs/dev/.env.s3 b/tests/generated_envs/create_root_agg/.envs/dev/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/create_root_agg/.envs/dev/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/custom_path_abs/.env.api b/tests/generated_envs/custom_path_abs/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/custom_path_abs/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/custom_path_abs/.env.app b/tests/generated_envs/custom_path_abs/.env.app deleted file mode 100644 index f853147..0000000 --- a/tests/generated_envs/custom_path_abs/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=8000 -DEBUG=true diff --git a/tests/generated_envs/custom_path_abs/.env.database b/tests/generated_envs/custom_path_abs/.env.database deleted file mode 100644 index 66353c8..0000000 --- a/tests/generated_envs/custom_path_abs/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=json-admin diff --git a/tests/generated_envs/custom_path_abs/.env.elastic b/tests/generated_envs/custom_path_abs/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/custom_path_abs/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/custom_path_abs/.env.kafka b/tests/generated_envs/custom_path_abs/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/custom_path_abs/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/custom_path_abs/.env.minio b/tests/generated_envs/custom_path_abs/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/custom_path_abs/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/custom_path_abs/.env.redis b/tests/generated_envs/custom_path_abs/.env.redis deleted file mode 100644 index da5493d..0000000 --- a/tests/generated_envs/custom_path_abs/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-yaml-master -PORT=6379 diff --git a/tests/generated_envs/custom_path_abs/.env.s3 b/tests/generated_envs/custom_path_abs/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/custom_path_abs/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.api b/tests/generated_envs/custom_path_rel/my_configs/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.app b/tests/generated_envs/custom_path_rel/my_configs/.env.app deleted file mode 100644 index f853147..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=8000 -DEBUG=true diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.database b/tests/generated_envs/custom_path_rel/my_configs/.env.database deleted file mode 100644 index 66353c8..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=json-admin diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.elastic b/tests/generated_envs/custom_path_rel/my_configs/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.kafka b/tests/generated_envs/custom_path_rel/my_configs/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.minio b/tests/generated_envs/custom_path_rel/my_configs/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.redis b/tests/generated_envs/custom_path_rel/my_configs/.env.redis deleted file mode 100644 index da5493d..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-yaml-master -PORT=6379 diff --git a/tests/generated_envs/custom_path_rel/my_configs/.env.s3 b/tests/generated_envs/custom_path_rel/my_configs/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/custom_path_rel/my_configs/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/explicit_patterns/.env.only_app b/tests/generated_envs/explicit_patterns/.env.only_app deleted file mode 100644 index c655ea7..0000000 --- a/tests/generated_envs/explicit_patterns/.env.only_app +++ /dev/null @@ -1 +0,0 @@ -VAR=app_val diff --git a/tests/generated_envs/explicit_patterns/.env.only_db b/tests/generated_envs/explicit_patterns/.env.only_db deleted file mode 100644 index 1b4446e..0000000 --- a/tests/generated_envs/explicit_patterns/.env.only_db +++ /dev/null @@ -1 +0,0 @@ -VAR=db_val diff --git a/tests/generated_envs/file_path_secret/.env.api b/tests/generated_envs/file_path_secret/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/file_path_secret/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/file_path_secret/.env.app b/tests/generated_envs/file_path_secret/.env.app deleted file mode 100644 index f853147..0000000 --- a/tests/generated_envs/file_path_secret/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=8000 -DEBUG=true diff --git a/tests/generated_envs/file_path_secret/.env.database b/tests/generated_envs/file_path_secret/.env.database deleted file mode 100644 index b293acf..0000000 --- a/tests/generated_envs/file_path_secret/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -FILE_DB_USER=file-user-admin -FILE_DB_PASS=file-secret-pass diff --git a/tests/generated_envs/file_path_secret/.env.elastic b/tests/generated_envs/file_path_secret/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/file_path_secret/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/file_path_secret/.env.kafka b/tests/generated_envs/file_path_secret/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/file_path_secret/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/file_path_secret/.env.minio b/tests/generated_envs/file_path_secret/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/file_path_secret/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/file_path_secret/.env.redis b/tests/generated_envs/file_path_secret/.env.redis deleted file mode 100644 index afae57f..0000000 --- a/tests/generated_envs/file_path_secret/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=yaml-file-host -PORT=6379 diff --git a/tests/generated_envs/file_path_secret/.env.s3 b/tests/generated_envs/file_path_secret/.env.s3 deleted file mode 100644 index 7b6f28e..0000000 --- a/tests/generated_envs/file_path_secret/.env.s3 +++ /dev/null @@ -1,2 +0,0 @@ -BUCKET=file-bucket -REGION=us-east-1 diff --git a/tests/generated_envs/flat_staging/.env.api b/tests/generated_envs/flat_staging/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/flat_staging/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/flat_staging/.env.app b/tests/generated_envs/flat_staging/.env.app deleted file mode 100644 index fb71b81..0000000 --- a/tests/generated_envs/flat_staging/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=3000 -DEBUG=true diff --git a/tests/generated_envs/flat_staging/.env.database b/tests/generated_envs/flat_staging/.env.database deleted file mode 100644 index 66353c8..0000000 --- a/tests/generated_envs/flat_staging/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=json-admin diff --git a/tests/generated_envs/flat_staging/.env.elastic b/tests/generated_envs/flat_staging/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/flat_staging/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/flat_staging/.env.kafka b/tests/generated_envs/flat_staging/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/flat_staging/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/flat_staging/.env.minio b/tests/generated_envs/flat_staging/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/flat_staging/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/flat_staging/.env.redis b/tests/generated_envs/flat_staging/.env.redis deleted file mode 100644 index da5493d..0000000 --- a/tests/generated_envs/flat_staging/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-yaml-master -PORT=6379 diff --git a/tests/generated_envs/flat_staging/.env.s3 b/tests/generated_envs/flat_staging/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/flat_staging/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/formats_parsing/.env.api b/tests/generated_envs/formats_parsing/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/formats_parsing/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/formats_parsing/.env.app b/tests/generated_envs/formats_parsing/.env.app deleted file mode 100644 index 1ff5552..0000000 --- a/tests/generated_envs/formats_parsing/.env.app +++ /dev/null @@ -1,4 +0,0 @@ -BASE_URL=https://app.com -PORT=9000 -DEBUG=true -SECRET=prod-exclusive-secret diff --git a/tests/generated_envs/formats_parsing/.env.database b/tests/generated_envs/formats_parsing/.env.database deleted file mode 100644 index ee8e38d..0000000 --- a/tests/generated_envs/formats_parsing/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=prod-json-admin diff --git a/tests/generated_envs/formats_parsing/.env.elastic b/tests/generated_envs/formats_parsing/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/formats_parsing/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/formats_parsing/.env.kafka b/tests/generated_envs/formats_parsing/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/formats_parsing/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/formats_parsing/.env.minio b/tests/generated_envs/formats_parsing/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/formats_parsing/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/formats_parsing/.env.redis b/tests/generated_envs/formats_parsing/.env.redis deleted file mode 100644 index 642dd95..0000000 --- a/tests/generated_envs/formats_parsing/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-prod-yaml-cluster -PORT=6379 diff --git a/tests/generated_envs/formats_parsing/.env.s3 b/tests/generated_envs/formats_parsing/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/formats_parsing/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.api b/tests/generated_envs/multi_env_nested/.envs/dev/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.app b/tests/generated_envs/multi_env_nested/.envs/dev/.env.app deleted file mode 100644 index f853147..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=8000 -DEBUG=true diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.database b/tests/generated_envs/multi_env_nested/.envs/dev/.env.database deleted file mode 100644 index 66353c8..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=json-admin diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic b/tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka b/tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.minio b/tests/generated_envs/multi_env_nested/.envs/dev/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.redis b/tests/generated_envs/multi_env_nested/.envs/dev/.env.redis deleted file mode 100644 index da5493d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-yaml-master -PORT=6379 diff --git a/tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 b/tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/dev/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.api b/tests/generated_envs/multi_env_nested/.envs/prod/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.app b/tests/generated_envs/multi_env_nested/.envs/prod/.env.app deleted file mode 100644 index 1ff5552..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.app +++ /dev/null @@ -1,4 +0,0 @@ -BASE_URL=https://app.com -PORT=9000 -DEBUG=true -SECRET=prod-exclusive-secret diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.database b/tests/generated_envs/multi_env_nested/.envs/prod/.env.database deleted file mode 100644 index ee8e38d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=prod-json-admin diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic b/tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka b/tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.minio b/tests/generated_envs/multi_env_nested/.envs/prod/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.redis b/tests/generated_envs/multi_env_nested/.envs/prod/.env.redis deleted file mode 100644 index 642dd95..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-prod-yaml-cluster -PORT=6379 diff --git a/tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 b/tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/prod/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.api b/tests/generated_envs/multi_env_nested/.envs/staging/.env.api deleted file mode 100644 index 2836fc9..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.api +++ /dev/null @@ -1 +0,0 @@ -KEY=api-key-12345 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.app b/tests/generated_envs/multi_env_nested/.envs/staging/.env.app deleted file mode 100644 index fb71b81..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.app +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL=https://app.com -PORT=3000 -DEBUG=true diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.database b/tests/generated_envs/multi_env_nested/.envs/staging/.env.database deleted file mode 100644 index 66353c8..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.database +++ /dev/null @@ -1,2 +0,0 @@ -DB_URL=postgres://db:5432 -DB_USER=json-admin diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic b/tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic deleted file mode 100644 index be87a23..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.elastic +++ /dev/null @@ -1 +0,0 @@ -URL=http://elastic:9200 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka b/tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka deleted file mode 100644 index f76b57d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.kafka +++ /dev/null @@ -1 +0,0 @@ -TOPIC=events-main diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.minio b/tests/generated_envs/multi_env_nested/.envs/staging/.env.minio deleted file mode 100644 index 955652d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.minio +++ /dev/null @@ -1 +0,0 @@ -ENDPOINT=http://minio:9000 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.redis b/tests/generated_envs/multi_env_nested/.envs/staging/.env.redis deleted file mode 100644 index da5493d..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.redis +++ /dev/null @@ -1,2 +0,0 @@ -HOST=redis-yaml-master -PORT=6379 diff --git a/tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 b/tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 deleted file mode 100644 index f971efd..0000000 --- a/tests/generated_envs/multi_env_nested/.envs/staging/.env.s3 +++ /dev/null @@ -1 +0,0 @@ -BUCKET=my-assets diff --git a/tests/generated_envs/single_prod/.env b/tests/generated_envs/single_prod/.env deleted file mode 100644 index d7ee7c3..0000000 --- a/tests/generated_envs/single_prod/.env +++ /dev/null @@ -1,13 +0,0 @@ -APP_BASE_URL=https://app.com -APP_PORT=9000 -APP_DEBUG=true -DATABASE_DB_URL=postgres://db:5432 -DATABASE_DB_USER=prod-json-admin -REDIS_HOST=redis-prod-yaml-cluster -REDIS_PORT=6379 -MINIO_ENDPOINT=http://minio:9000 -S3_BUCKET=my-assets -KAFKA_TOPIC=events-main -ELASTIC_URL=http://elastic:9200 -API_KEY=api-key-12345 -APP_SECRET=prod-exclusive-secret diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 54c19d6..4e9b7d1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -33,7 +33,7 @@ def ssh_container(): # Connection parameters host = "127.0.0.1" - port = 2222 + ports = [2222, 2223] user = "root" password = "root" @@ -43,25 +43,27 @@ def ssh_container(): for i in range(retries): try: - # Check if port is listening - with socket.create_connection((host, port), timeout=2): - pass - - # Verify SSH actually works - conn = Connection( - host=host, - port=port, - user=user, - connect_kwargs={ - "password": password, - "look_for_keys": False, - "allow_agent": False, - }, - ) - conn.run("echo 'SSH Ready'", hide=True) - conn.close() + # Check if ports are listening + for port in ports: + with socket.create_connection((host, port), timeout=2): + pass + + # Verify SSH actually works + conn = Connection( + host=host, + port=port, + user=user, + connect_kwargs={ + "password": password, + "look_for_keys": False, + "allow_agent": False, + }, + ) + conn.run("echo 'SSH Ready'", hide=True) + conn.close() + ready = True - print("✅ SSH Container Ready") + print("✅ SSH Containers Ready") break except Exception as e: if i < retries - 1: @@ -72,10 +74,11 @@ def ssh_container(): if not ready: # Capture logs before failing logs = subprocess.run( - ["docker", "logs", "tests-ssh-server-1"], + ["docker", "compose", "-f", docker_compose_file, "logs"], capture_output=True, text=True, stdin=subprocess.DEVNULL, + cwd=tests_dir, ) print(f"Container logs:\n{logs.stdout}") subprocess.run( @@ -84,9 +87,15 @@ def ssh_container(): stdin=subprocess.DEVNULL, cwd=tests_dir, ) - pytest.fail("Could not connect to SSH container after 30 retries") - - yield {"host": host, "port": port, "user": user, "password": password} + pytest.fail("Could not connect to SSH containers after 30 retries") + + yield { + "host": host, + "port": ports[0], + "second_port": ports[1], + "user": user, + "password": password, + } # Teardown print("🛑 Stopping SSH container...") diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index 116a022..651d916 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -9,3 +9,14 @@ services: - ../:/opt/metaldeploy_source - ./generated_envs:/opt/metaldeploy_tests restart: always + + ssh-server-2: + build: + context: . + dockerfile: Dockerfile.test-ssh + ports: + - "2223:22" + volumes: + - ../:/opt/metaldeploy_source + - ./generated_envs_2:/opt/metaldeploy_tests + restart: always diff --git a/tests/integration/test_integration_full.py b/tests/integration/test_integration_full.py index 3306ae8..7fee46b 100644 --- a/tests/integration/test_integration_full.py +++ b/tests/integration/test_integration_full.py @@ -74,6 +74,57 @@ def test_artifact_copying(integration_conn, clean_remote_dir, monkeypatch, tmp_p assert "console.log('hello');" in content +@pytest.mark.integration +def test_multi_host_execution(ssh_container, clean_remote_dir, monkeypatch, capsys): + """Test deploying to multiple hosts in parallel.""" + from fabric import Connection + + from src.orchestrator import handle_connection + + # 1. Setup Config + host1 = f"{ssh_container['host']}:{ssh_container['port']}" + host2 = f"{ssh_container['host']}:{ssh_container['second_port']}" + + monkeypatch.setenv("REMOTE_HOST", f"{host1}, {host2}") + monkeypatch.setenv("REMOTE_USER", ssh_container["user"]) + monkeypatch.setenv("REMOTE_PASSWORD", ssh_container["password"]) + monkeypatch.setenv("ENV_FILES_GENERATE", "false") # Simplify + monkeypatch.setenv("DEPLOYMENT_TYPE", "baremetal") + monkeypatch.setenv("DEPLOY_COMMAND", "echo deployment_simulated") + + # 2. Run + # We need to handle the fact that 'clean_remote_dir' only cleans the first one usually? + # Actually clean_remote_dir fixture uses 'integration_conn' which uses port 2222. + # So host2 might be dirty or not exist. + # But code does 'mkdir -p REMOTE_DIR'. + # We'll rely on unique dir names to avoid conflict if dirty. + + target_dir = "/opt/metaldeploy_tests/multi_host_test" + monkeypatch.setenv("REMOTE_DIR", target_dir) + monkeypatch.setenv("GIT_DIR", f"{target_dir}/repo") + + # Reload config in main process so it picks up the env vars + config.load() + + # Run orchestration + handle_connection() + + # 3. Verify on Host 1 + conn_args = { + "password": ssh_container["password"], + "look_for_keys": False, + "allow_agent": False, + } + conn1 = Connection(host=host1, user=ssh_container["user"], connect_kwargs=conn_args) + res1 = conn1.run(f"test -d {target_dir}", warn=True) + assert res1.ok + + # 4. Verify on Host 2 + conn2 = Connection(host=host2, user=ssh_container["user"], connect_kwargs=conn_args) + res2 = conn2.run(f"test -d {target_dir}", warn=True) + assert res2.ok + + # ------------------------------------------------------------------------------ # HYPER-EXHAUSTIVE PERMUTATION TESTS # ------------------------------------------------------------------------------ diff --git a/tests/unit/test_artifacts.py b/tests/unit/test_artifacts.py index d62568c..96ef466 100644 --- a/tests/unit/test_artifacts.py +++ b/tests/unit/test_artifacts.py @@ -1,50 +1,54 @@ from unittest.mock import MagicMock, patch -import os -import tarfile + import pytest + from src import config from src.connection import copy_artifacts + @pytest.fixture def mock_conn(): conn = MagicMock() conn.run.return_value = MagicMock(ok=True, stdout="") return conn + def test_copy_artifacts_no_config(mock_conn, monkeypatch): monkeypatch.setattr(config, "COPY_ARTIFACTS", []) copy_artifacts(mock_conn) mock_conn.put.assert_not_called() + def test_copy_artifacts_local_missing(mock_conn, monkeypatch, capsys): monkeypatch.setattr(config, "COPY_ARTIFACTS", [("missing_local", "/remote")]) copy_artifacts(mock_conn) mock_conn.put.assert_not_called() assert "not found, skipping" in capsys.readouterr().out + def test_copy_artifacts_success(mock_conn, monkeypatch, tmp_path): # Create a dummy local file local_file = tmp_path / "test.txt" local_file.write_text("content") - + # Configure artifacts monkeypatch.setattr(config, "COPY_ARTIFACTS", [(str(local_file), "/app/test.txt")]) monkeypatch.setattr(config, "GIT_DIR", "/app") - - with patch("tarfile.open") as mock_tar_open, \ - patch("src.connection.run_command") as mock_run_cmd: - + + with patch("tarfile.open") as mock_tar_open, patch( + "src.connection.run_command" + ) as mock_run_cmd: mock_tar = MagicMock() mock_tar_open.return_value.__enter__.return_value = mock_tar - + copy_artifacts(mock_conn) - + # Verify tarball creation mock_tar.add.assert_called_with(str(local_file), arcname="test.txt") - + # Verify upload assert mock_conn.put.called - + # Verify extraction logic runs calls = [str(c) for c in mock_run_cmd.mock_calls] assert any("mkdir -p /app" in c for c in calls) diff --git a/tests/unit/test_multi_server.py b/tests/unit/test_multi_server.py new file mode 100644 index 0000000..af0c6b5 --- /dev/null +++ b/tests/unit/test_multi_server.py @@ -0,0 +1,99 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from src import orchestrator +from src.config import config + + +@pytest.fixture +def mock_executor(): + with patch("concurrent.futures.ProcessPoolExecutor") as mock_pool: + executor_instance = MagicMock() + mock_pool.return_value.__enter__.return_value = executor_instance + yield executor_instance + + +def test_single_host_direct_call(monkeypatch): + """Test that single host bypasses the executor.""" + monkeypatch.setattr(config, "REMOTE_HOST", "1.1.1.1") + + # We must patch where it's used. Since handle_connection is in src.orchestrator, + # and it calls deploy_single_host which is also in src.orchestrator, + # patching src.orchestrator.deploy_single_host should work. + # However, to be safe and avoid network usage if it fails, we also mock Connection. + + with patch("src.orchestrator.deploy_single_host") as mock_single: + orchestrator.handle_connection() + mock_single.assert_called_once() + + # Check with Executor mocked - should NOT be called + with patch("concurrent.futures.ProcessPoolExecutor") as mock_pool, patch( + "src.orchestrator.deploy_single_host" + ) as mock_single: + orchestrator.handle_connection() + mock_pool.assert_not_called() + mock_single.assert_called_once() + + +def test_multi_host_distribution(monkeypatch, mock_executor): + """Test that multiple hosts spawn multiple workers with correct overrides.""" + monkeypatch.setattr(config, "REMOTE_HOST", "h1, h2, h3") + monkeypatch.setattr( + config, "REMOTE_USER", "u1, u2" + ) # Short list, should reuse last (u2 for h3) + + # Mock future to avoid actual execution waiting + mock_future = MagicMock() + mock_future.result.return_value = None + mock_executor.submit.return_value = mock_future + + with patch("concurrent.futures.as_completed") as mock_as_completed: + # as_completed yields futures. + # In handle_connection: for future in as_completed(futures): ... + # We need it to yield the mock_future objects we submitted. + # Since logic submits multiple times, submit returns same mock_future? + # Better: make submit return a unique mock for each call? + + f1, f2, f3 = MagicMock(), MagicMock(), MagicMock() + f1.result.return_value = None + f2.result.return_value = None + f3.result.return_value = None + mock_executor.submit.side_effect = [f1, f2, f3] + + mock_as_completed.return_value = [f1, f2, f3] + + orchestrator.handle_connection() + + assert mock_executor.submit.call_count == 3 + + # Inspect calls + calls = mock_executor.submit.call_args_list + + # Call 1: h1, u1 + args1, _ = calls[0] + assert args1[1]["REMOTE_HOST"] == "h1" + assert args1[1]["REMOTE_USER"] == "u1" + + # Call 2: h2, u2 + args2, _ = calls[1] + assert args2[1]["REMOTE_HOST"] == "h2" + assert args2[1]["REMOTE_USER"] == "u2" + + # Call 3: h3, u2 (re-use last user) + args3, _ = calls[2] + assert args3[1]["REMOTE_HOST"] == "h3" + assert args3[1]["REMOTE_USER"] == "u2" + + +def test_worker_reloads_config(monkeypatch): + """Test that the worker function reloads config.""" + # We can't easily test cross-process state here, but we can verify the function calls config.load + with patch("src.config.config.load") as mock_load, patch( + "src.orchestrator.deploy_single_host" + ) as mock_deploy: + overrides = {"REMOTE_HOST": "test-host"} + orchestrator.deploy_worker(overrides) + + mock_load.assert_called_with(overrides) + mock_deploy.assert_called_once() From 66647301eb992fe7023f5f2be827c5345f6fb005 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 15:41:20 +0100 Subject: [PATCH 12/13] refactor(config): Exclude venv directories from code formatting and sorting tools to improve maintainability --- changelogs/2026-01-29_15-37-50.md | 4 ++-- changelogs/2026-01-29_15-41-00.md | 9 +++++++++ pyproject.toml | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 changelogs/2026-01-29_15-41-00.md diff --git a/changelogs/2026-01-29_15-37-50.md b/changelogs/2026-01-29_15-37-50.md index 087ecba..b3eb25d 100644 --- a/changelogs/2026-01-29_15-37-50.md +++ b/changelogs/2026-01-29_15-37-50.md @@ -16,6 +16,6 @@ - Removed numerous test environment files under `tests/generated_envs/` as they are no longer needed or have been replaced. ### Fixed -- No specific bug fixes are mentioned in the provided diff, but the updates to support multi-server deployment may implicitly address previous limitations or issues related to single-host deployments. +- No specific bug fixes are mentioned in the provided diff, but the updates to support multi-server deployment may implicitly address previous limitations or issues related to single-host deployments. -Note: The changelog focuses on changes from a user/developer perspective, highlighting new features, changes to existing functionality, and removals. Since the provided diff does not explicitly mention bug fixes or security updates, those sections are not included unless directly inferred from the changes (e.g., implicit fixes through new feature implementation). \ No newline at end of file +Note: The changelog focuses on changes from a user/developer perspective, highlighting new features, changes to existing functionality, and removals. Since the provided diff does not explicitly mention bug fixes or security updates, those sections are not included unless directly inferred from the changes (e.g., implicit fixes through new feature implementation). diff --git a/changelogs/2026-01-29_15-41-00.md b/changelogs/2026-01-29_15-41-00.md new file mode 100644 index 0000000..61c6697 --- /dev/null +++ b/changelogs/2026-01-29_15-41-00.md @@ -0,0 +1,9 @@ +# Changelog + +## [Unreleased] + +### Removed +- Numerous test environment files under `tests/generated_envs/` as they are no longer needed or have been replaced. + +### Changed +- Updated configuration to exclude additional directories (`\.venv`, `venv`) from code formatting and sorting tools (`black` and `isort`). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index df92d93..5972f18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,12 +45,12 @@ markers = [ [tool.black] line-length = 100 target-version = ['py39', 'py310', 'py311', 'py312'] -extend-exclude = 'generated_envs|generated_envs_2' +extend-exclude = 'generated_envs|generated_envs_2|\.venv|venv' [tool.isort] profile = "black" line_length = 100 -skip = ["generated_envs", "generated_envs_2"] +skip = ["generated_envs", "generated_envs_2", ".venv", "venv"] [tool.coverage.run] source = ["src", "main.py"] From 96f3088e6028807aece833187782f0c72a0cd199 Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 16:01:20 +0100 Subject: [PATCH 13/13] test: Update test to use patch.object for mocking config.load --- .flake8 | 2 +- .github/workflows/release-tagging.yml | 30 +++++++ changelogs/2026-01-29_15-41-00.md | 2 +- changelogs/2026-01-29_16-00-53.md | 17 ++++ coverage.xml | 119 +++++++++++++------------- pytest.ini | 1 + tests/unit/test_multi_server.py | 2 +- 7 files changed, 109 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/release-tagging.yml create mode 100644 changelogs/2026-01-29_16-00-53.md diff --git a/.flake8 b/.flake8 index 3bcf39f..ab54c25 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] max-line-length = 100 ignore = E501, W503, E203 -exclude = .git, __pycache__, *.pyc, .pytest_cache, .venv, venv, generated_envs, generated_envs_2, tests/generated_envs, tests/generated_envs_2, tests/integration/generated_envs, tests/integration/generated_envs_2 +exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,*.egg-info,.venv,venv,generated_envs,generated_envs_2,tests/generated_envs*,tests/integration/generated_envs* diff --git a/.github/workflows/release-tagging.yml b/.github/workflows/release-tagging.yml new file mode 100644 index 0000000..5b59cea --- /dev/null +++ b/.github/workflows/release-tagging.yml @@ -0,0 +1,30 @@ +name: Update Major Version Tags + +on: + push: + tags: + - 'v*.*.*' # Trigger only on full semantic version tags + +jobs: + tag: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Update Major Tag + run: | + # Extract major version (e.g., v1 from v1.2.3) + MAJOR=$(echo "${{ github.ref_name }}" | cut -d. -f1) + + echo "Updating major tag: $MAJOR to point to ${{ github.ref_name }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Force create/move major tag + git tag -f "$MAJOR" "${{ github.ref_name }}" + git push origin "$MAJOR" --force diff --git a/changelogs/2026-01-29_15-41-00.md b/changelogs/2026-01-29_15-41-00.md index 61c6697..c926b44 100644 --- a/changelogs/2026-01-29_15-41-00.md +++ b/changelogs/2026-01-29_15-41-00.md @@ -6,4 +6,4 @@ - Numerous test environment files under `tests/generated_envs/` as they are no longer needed or have been replaced. ### Changed -- Updated configuration to exclude additional directories (`\.venv`, `venv`) from code formatting and sorting tools (`black` and `isort`). \ No newline at end of file +- Updated configuration to exclude additional directories (`\.venv`, `venv`) from code formatting and sorting tools (`black` and `isort`). diff --git a/changelogs/2026-01-29_16-00-53.md b/changelogs/2026-01-29_16-00-53.md new file mode 100644 index 0000000..3114b11 --- /dev/null +++ b/changelogs/2026-01-29_16-00-53.md @@ -0,0 +1,17 @@ +# Changelog + +## [Unreleased] + +### Changed +- Updated configuration to exclude additional directories from code formatting and sorting tools. +- Updated pytest configuration to exclude additional directories from testing. +- Updated test `test_worker_reloads_config` to use `patch.object` instead of `patch` for mocking `config.load`. + +### Fixed +- Improved code coverage by updating the `coverage.xml` file to reflect changes in the codebase. + +Note: The changes to `coverage.xml` and `tests/unit/test_multi_server.py` are not explicitly mentioned in the changelog as they are not directly related to user-facing changes. However, they are included in the commit and are reflected in the changelog as "Improved code coverage". + +The changes to `.flake8` and `pytest.ini` are related to code formatting and testing, which are not typically included in a user-facing changelog. However, they are included here as they may be relevant to developers working with the codebase. + +The actual changes to the codebase are not explicitly mentioned in the changelog as they are not directly related to user-facing changes. The changelog only includes changes that are relevant to users or developers working with the codebase. \ No newline at end of file diff --git a/coverage.xml b/coverage.xml index 1db0a5f..445281f 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /workspace/personal/MetalDeploy/src - + @@ -493,118 +493,115 @@ - + - - + + - - + + + + + - - - - - + + + + + + + + + + + + + - - - + + - - - - + + + - + + + - - + + - + + - + - - - - + + - - - + + + - - - - - - - + + + + + - - + + + + + - - - + - - - - + + + - - - - - - - - - - - - - - + + + diff --git a/pytest.ini b/pytest.ini index ff36de0..0bc173b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -11,3 +11,4 @@ markers = unit: Unit tests that don't require external dependencies integration: Integration tests that require external services (run with -s) slow: Tests that take a long time to run +norecursedirs = .venv venv generated_envs generated_envs_2 tests/generated_envs* tests/integration/generated_envs* whorkaz diff --git a/tests/unit/test_multi_server.py b/tests/unit/test_multi_server.py index af0c6b5..d7b6d7c 100644 --- a/tests/unit/test_multi_server.py +++ b/tests/unit/test_multi_server.py @@ -89,7 +89,7 @@ def test_multi_host_distribution(monkeypatch, mock_executor): def test_worker_reloads_config(monkeypatch): """Test that the worker function reloads config.""" # We can't easily test cross-process state here, but we can verify the function calls config.load - with patch("src.config.config.load") as mock_load, patch( + with patch.object(config, "load") as mock_load, patch( "src.orchestrator.deploy_single_host" ) as mock_deploy: overrides = {"REMOTE_HOST": "test-host"}