From 8e7b2adf1398374fcf759ec66065a2ffcb59f32d Mon Sep 17 00:00:00 2001 From: hordunlarmy Date: Thu, 29 Jan 2026 19:48:55 +0100 Subject: [PATCH] feat(env): Introduce raw ENV secret templating and remove env_blob input --- README.md | 8 +- action.yml | 4 - changelogs/2026-01-29_19-11-03.md | 2 +- changelogs/2026-01-29_19-47-44.md | 16 + changelogs/2026-01-29_19-48-25.md | 11 + coverage.xml | 294 +++++++++--------- docs/env-generation.md | 13 +- src/env_manager.py | 167 +++++----- .../auto_dev/.envs/dev/.env.app | 4 + .../auto_dev/.envs/dev/.env.redis | 3 + .../create_root_agg/.envs/dev/.env.app | 4 + .../create_root_agg/.envs/dev/.env.redis | 3 + .../generated_envs/custom_path_abs/.env.app | 4 + .../generated_envs/custom_path_abs/.env.redis | 3 + .../custom_path_rel/my_configs/.env.app | 4 + .../custom_path_rel/my_configs/.env.redis | 3 + .../generated_envs/file_path_secret/.env.app | 4 + .../file_path_secret/.env.database | 2 + .../file_path_secret/.env.redis | 2 + .../generated_envs/file_path_secret/.env.s3 | 2 + .../generated_envs/flat_staging/.env.app | 4 + .../generated_envs/flat_staging/.env.redis | 3 + .../generated_envs/formats_parsing/.env.app | 4 + .../generated_envs/formats_parsing/.env.redis | 3 + .../multi_env_nested/.envs/dev/.env.app | 4 + .../multi_env_nested/.envs/dev/.env.redis | 3 + .../multi_env_nested/.envs/prod/.env.app | 4 + .../multi_env_nested/.envs/prod/.env.redis | 3 + .../multi_env_nested/.envs/staging/.env.app | 4 + .../multi_env_nested/.envs/staging/.env.redis | 3 + tests/unit/test_env_manager.py | 35 +-- 31 files changed, 361 insertions(+), 262 deletions(-) create mode 100644 changelogs/2026-01-29_19-47-44.md create mode 100644 changelogs/2026-01-29_19-48-25.md diff --git a/README.md b/README.md index 4dfcfba..fbdbe71 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ metaldeploy --host 1.2.3.4 --user root --ssh-key ~/.ssh/id_rsa --type docker ssh_key: ${{ secrets.SSH_PRIVATE_KEY }} environment: prod # Pass all secrets in one go (Zero-Config) - env_blob: ${{ toJSON(secrets) }} + # environment: prod ``` ### Advanced Example with Docker @@ -105,9 +105,7 @@ Copy specific files or directories (like `node_modules` or `dist/`) to the serve ### Secure Secret Management -MetalDeploy is designed for Zero-Config secret management. Use the **`env_blob`** input to pass all repository secrets without mapping them manually. - -- **Bulk Injection**: Use `env_blob: ${{ toJSON(secrets) }}` to securely tunnel all secrets starting with `ENV_` to your server. +- **Bulk Injection**: Use `ENV` to securely tunnel your raw variable block to your server. - **Manual Overrides**: Use the standard GitHub Action **`env:`** block to override secrets for specific steps. ```yaml @@ -116,9 +114,7 @@ MetalDeploy is designed for Zero-Config secret management. Use the **`env_blob`* env: ENV_APP_PORT: 3000 # This wins over repository secrets with: - env_blob: ${{ toJSON(secrets) }} env_files_generate: true - ... ``` ### Multi-Server Deployment diff --git a/action.yml b/action.yml index c711409..16ad4b1 100644 --- a/action.yml +++ b/action.yml @@ -102,9 +102,6 @@ inputs: 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 - env_blob: - description: 'A block of environment variables in KEY=VALUE format (e.g., from a secret) to be included in .env files without prefixes.' - required: false outputs: deployment_status: description: 'Deployment status (success/failed)' @@ -204,5 +201,4 @@ runs: ENV_FILES_CREATE_ROOT: ${{ inputs.env_files_create_root }} ENV_FILES_FORMAT: ${{ inputs.env_files_format }} COPY_ARTIFACTS: ${{ inputs.copy_artifacts }} - ENV_BLOB: ${{ inputs.env_blob }} GITHUB_WORKSPACE: ${{ github.workspace }} diff --git a/changelogs/2026-01-29_19-11-03.md b/changelogs/2026-01-29_19-11-03.md index 7f59fe8..381537d 100644 --- a/changelogs/2026-01-29_19-11-03.md +++ b/changelogs/2026-01-29_19-11-03.md @@ -3,4 +3,4 @@ ## [Unreleased] ### Changed -- Enhanced environment variable parsing for `env` format secrets. The parser now robustly handles multi-line values, correctly ignores comment lines, and accurately strips leading/trailing quotes from string values, improving reliability when processing complex environment variable strings. \ No newline at end of file +- Enhanced environment variable parsing for `env` format secrets. The parser now robustly handles multi-line values, correctly ignores comment lines, and accurately strips leading/trailing quotes from string values, improving reliability when processing complex environment variable strings. diff --git a/changelogs/2026-01-29_19-47-44.md b/changelogs/2026-01-29_19-47-44.md new file mode 100644 index 0000000..4ee83ea --- /dev/null +++ b/changelogs/2026-01-29_19-47-44.md @@ -0,0 +1,16 @@ +# Changelog + +## [Unreleased] + +### Added +- Introduced a new intelligent merging mechanism for environment files, allowing the `ENV` secret to serve as a raw template. This preserves comments, blank lines, and custom formatting in your `.env` files while seamlessly merging new or overridden variables. + +### Changed +- **Environment Variable Handling Refined**: + - The core logic for generating `.env` files has been significantly refactored to prioritize the new raw template merging capabilities. + - The `parse_all_in_one_secret` function now includes a `strip_quotes` parameter, providing more granular control over how environment variable values are processed. +- **Improved Security**: Environment files generated on the remote server are now created with more restrictive `0o600` permissions, enhancing secret protection. +- **Documentation Updates**: The `README.md` and `docs/env-generation.md` have been updated to reflect the new environment variable management approach and the removal of the `env_blob` input. + +### Removed +- The `env_blob` input from `action.yml` has been removed. Its functionality is now superseded by the enhanced capabilities of the `ENV` secret for passing raw environment variable blocks and templating. \ No newline at end of file diff --git a/changelogs/2026-01-29_19-48-25.md b/changelogs/2026-01-29_19-48-25.md new file mode 100644 index 0000000..ee1b1d4 --- /dev/null +++ b/changelogs/2026-01-29_19-48-25.md @@ -0,0 +1,11 @@ +# Changelog + +## [Unreleased] + +### Changed +- **Enhanced Environment File Templating**: The `ENV` secret can now be used as a base template for generated `.env` files. This allows for more structured and human-readable configurations by intelligently merging new or overriding environment variables while preserving existing comments, blank lines, and custom formatting within the template. +- **Stricter File Permissions**: Generated environment files (e.g., `.env`, `.env.app`) are now created with more restrictive `0o600` permissions (read/write only for the file owner), enhancing security by limiting access. +- **Refined Secret Parsing**: Further internal refinements to the environment variable parsing logic improve robustness when handling various formats and merging into base templates, ensuring more reliable processing of complex environment variable strings. + +### Removed +- The `env_blob` input and its associated functionality for bulk secret injection have been removed. Users should now leverage the `ENV` secret for providing raw blocks of variables that serve as a base template for generated environment files. \ No newline at end of file diff --git a/coverage.xml b/coverage.xml index 874a309..a5d880a 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,12 +1,12 @@ - + /workspace/personal/MetalDeploy/src - + @@ -160,7 +160,7 @@ - + @@ -172,280 +172,286 @@ - - + - - + - - - + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + - - - + + + + - + - + - + - + - - - + + - - + + + - - - + + - + - + - - - - + + + - - - + + + + + + + - - - - + + - - + + - + - - - + - - - - - - + + + + + + + - + - + - + + - + + + - + - - - - - - + - - + + + + + + + - - - - + + - - - + + - + + - - - - - - + + - - - - - - - - + + + + + + + + + + - - - - + + + + + + + - - + - - - - + + - - + + - - - - - - - - - + + + + + - + - - - - + + + - - + + + + - - + + + + + + - - - - + - - - + + - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/docs/env-generation.md b/docs/env-generation.md index 31cb40d..c373af7 100644 --- a/docs/env-generation.md +++ b/docs/env-generation.md @@ -8,9 +8,8 @@ MetalDeploy includes powerful environment file generation capabilities that auto - ✅ **Flexible File Structures** - Single `.env` file, flat `.env.*` files, or nested `.envs/{environment}/` organization - ✅ **Priority System** - Environment-specific secrets override base secrets automatically - ✅ **All-in-One Secret Support** - Store multiple variables in single secrets -- ✅ **Global Literal Blob** - Support for a literal `ENV` secret for non-prefixed global dumps - ✅ **Strict Filtering** - Only processes keys starting with `ENV_` or the literal `ENV` to ensure security -- ✅ **Secure Handling** - Files created with `0o604` permissions, no secret logging +- ✅ **Secure Handling** - Files created with `0o600` permissions, no secret logging - ✅ **Secrets & Variables** - Supports both GitHub Secrets (encrypted) and GitHub Variables (plaintext) ## How it Works @@ -20,14 +19,14 @@ MetalDeploy includes powerful environment file generation capabilities that auto - **Literal `ENV`**: Treated as a non-prefixed global blob. Its contents go directly into the `.env` file without prefixes. - **`ENV_COMPONENT_...`**: Treated as part of a specific component (e.g., `ENV_APP_...` goes to `.env.app`). - **`ENV_ENVIRONMENT_...`**: Identifies prefixes matching common environments (`PROD`, `STAGING`, `DEV`, `TEST`) **plus** your current `environment` input. -3. **Strict Filtering**: If using `env_blob: ${{ toJSON(secrets) }}`, MetalDeploy **only** processes keys inside that blob that start with `ENV_` or are the literal `ENV`. Other secrets (like `STRIPE_KEY` or `GITHUB_TOKEN`) are ignored for security unless specifically prefixed. +3. **Strict Filtering**: MetalDeploy **only** processes secrets that start with `ENV_` or the literal `ENV`. Other secrets (like `STRIPE_KEY` or `GITHUB_TOKEN`) are ignored for security unless specifically prefixed. ## Priority System (Last one wins) The priority system ensures that specific overrides always take precedence: -1. **`env_blob` Contents** (Lowest) - Everything inside your `env_blob` (like `toJSON(secrets)`) is loaded first. +1. **Base Variables** (Lowest) + Individual secrets or raw blocks are loaded first. 2. **Explicit Workflow `env:` Variables** (Medium) Variables you map directly in your workflow's `env:` block win over the blob. 3. **Environment Overrides** (Highest) @@ -45,7 +44,6 @@ Here is exactly how the `env:` block looks alongside the `with:` block in a GitH ENV_APP_PORT: 9090 # This wins over any other PORT setting with: env_files_generate: 'true' - env_blob: ${{ toJSON(secrets) }} environment: 'staging' remote_host: ${{ secrets.REMOTE_HOST }} # ... other inputs ... @@ -55,11 +53,8 @@ Here is exactly how the `env:` block looks alongside the `with:` block in a GitH For the simplest setup, pass all secrets in one go: -```yaml - uses: ./ with: env_files_generate: 'true' - env_blob: ${{ toJSON(secrets) }} -``` **Security Check**: Only secrets you have named with the `ENV_` prefix in your repository settings will be processed. All other private secrets remain untouched. diff --git a/src/env_manager.py b/src/env_manager.py index b87f0cf..c92eaa5 100644 --- a/src/env_manager.py +++ b/src/env_manager.py @@ -10,7 +10,9 @@ from src.connection import run_command -def parse_all_in_one_secret(secret_content: str, format_hint: str = "auto") -> Dict[str, str]: +def parse_all_in_one_secret( + secret_content: str, format_hint: str = "auto", strip_quotes: bool = True +) -> Dict[str, str]: """Parse all-in-one secret with multiple format support""" if not secret_content: return {} @@ -95,11 +97,12 @@ def parse_all_in_one_secret(secret_content: str, format_hint: str = "auto") -> D # Post-process the value: # 1. Strip trailing delimiters (commas/spaces) - # 2. Handle quoting: if starts and ends with ", strip them + # 2. Handle quoting: only if strip_quotes is True value = raw_value.rstrip(" ,") - if (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") + if strip_quotes and ( + (value.startswith('"') and value.endswith('"')) + or (value.startswith("'") and value.endswith("'")) ): # Strip outside quotes but keep internal content (including newlines/escapes) value = value[1:-1] @@ -111,6 +114,45 @@ def parse_all_in_one_secret(secret_content: str, format_hint: str = "auto") -> D return {} +def merge_raw_env(base_content: str, overrides: Dict[str, str]) -> str: + """Merge overrides into base_content while preserving comments and formatting""" + if not base_content: + return "\n".join([f"{k}={v}" for k, v in overrides.items()]) + + lines = base_content.splitlines() + result_lines = [] + processed_keys = set() + + for line in lines: + stripped = line.strip() + # Preserve comments and empty lines + if not stripped or stripped.startswith("#"): + result_lines.append(line) + continue + + # Look for KEY=VALUE + if "=" in stripped: + key = stripped.split("=", 1)[0].strip() + if key in overrides: + # Replace the line with the override + result_lines.append(f"{key}={overrides[key]}") + processed_keys.add(key) + continue + + result_lines.append(line) + + # Append new keys that weren't in the base content + for k, v in overrides.items(): + if k not in processed_keys: + # If the last line isn't empty, add a separator if this is the first new key + if not processed_keys and result_lines and result_lines[-1].strip(): + result_lines.append("") + result_lines.append(f"{k}={v}") + processed_keys.add(k) + + return "\n".join(result_lines) + + def detect_file_patterns( all_env_vars: Dict[str, str], structure: str, environment: str = "" ) -> List[str]: @@ -233,7 +275,7 @@ def merge_env_vars_by_priority( for k, v in all_env_vars.items(): if k.startswith("ENV_"): # Skip internal configuration and raw blobs - if k.startswith("ENV_FILES_") or k == "ENV" or k == "ENV_BLOB": + if k.startswith("ENV_FILES_") or k == "ENV": continue # Skip environment-specific ones @@ -322,97 +364,62 @@ def merge_env_vars_by_priority( return merged -def detect_environment_secrets() -> Dict[str, Dict[str, str]]: - """Auto-detect and parse environment-specific secrets with priority system""" - # 1. Parse Blobs (base layer) - # This supports env_blob: ${{ toJSON(secrets) }} or ENV: - blob_content = os.environ.get("ENV") or os.environ.get("ENV_BLOB") - working_vars = {} - special_global_base = {} - - if blob_content: - # Check if the blob content itself is JSON/YAML - raw_processed = blob_content.replace("\\n", "\n").strip() - is_json_or_yaml = (raw_processed.startswith("{") and raw_processed.endswith("}")) or ( - raw_processed.startswith("[") and raw_processed.endswith("]") - ) +def detect_environment_secrets() -> Dict[str, str]: + """Auto-detect secrets and return raw file content mapped by pattern""" + # 1. Get Base Template (ENV) + base_template = os.environ.get("ENV") or "" - parsed_blob = parse_all_in_one_secret(blob_content, config.ENV_FILES_FORMAT) - - if parsed_blob: - if is_json_or_yaml: - # Bulk JSON/YAML blob (from toJSON(secrets)) - for k, v in parsed_blob.items(): - if k == "ENV": - # This is the literal ENV secret inside a bulk blob - global_parsed = parse_all_in_one_secret(v, config.ENV_FILES_FORMAT) - if global_parsed: - special_global_base.update(global_parsed) - print(f"DEBUG: Found {len(global_parsed)} keys inside nested 'ENV' key") - elif k.startswith("ENV_"): - # This is a structured secret (e.g. ENV_APP_...) - working_vars[k] = v - # OTHER KEYS ARE IGNORED (strictly no global fallout) - else: - # Direct raw block of variables (e.g. ENV: ) - # Everything in a raw block goes to the global base - special_global_base.update(parsed_blob) - print(f"DEBUG: Found {len(parsed_blob)} keys in direct raw block") - - # 2. Add real environment variables (higher priority) - # This handles individual secrets mapped in the workflow 'env:' block + # 2. Collect Overrides (ENV_...) + # We group variables by their intended file pattern + raw_overrides = {} for k, v in os.environ.items(): if k.startswith("ENV_") and not k.startswith("ENV_FILES_"): - working_vars[k] = v - elif k == "ENV": - # If ENV is passed directly as a string (and wasn't handled as a bulk blob above) - try: - # Only process if it's NOT a bulk JSON (sanity check) - if not (v.strip().startswith("{") and v.strip().endswith("}")): - global_parsed = parse_all_in_one_secret(v, config.ENV_FILES_FORMAT) - if global_parsed: - special_global_base.update(global_parsed) - print(f"DEBUG: Found {len(global_parsed)} keys in direct ENV secret") - except Exception: - pass - - if not working_vars and not special_global_base: - return {} + raw_overrides[k] = v structure = config.ENV_FILES_STRUCTURE if structure == "single": patterns = [".env"] else: - patterns = detect_file_patterns(working_vars, structure, config.ENVIRONMENT) + patterns = detect_file_patterns(raw_overrides, 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 = {} - # 3. Apply the literal global base layer to the main .env file - if special_global_base: - target_file = patterns[0] if patterns else ".env" - result[target_file] = special_global_base.copy() for pattern in patterns: - merged_vars = merge_env_vars_by_priority(working_vars, config.ENVIRONMENT, pattern) - if merged_vars: - if pattern in result: - result[pattern].update(merged_vars) - else: - result[pattern] = merged_vars + # Merge individual vars for this pattern + # Note: merge_env_vars_by_priority already handles prefixes and blobs + # and returns a final flattened dict of KEY:VALUE + overrides = merge_env_vars_by_priority(raw_overrides, config.ENVIRONMENT, pattern) + + if pattern == ".env": + # For the main .env, we use the base template + result[pattern] = merge_raw_env(base_template, overrides) + else: + # For patterned files, we just generate the KV pairs (or we could support ENV_{COMP}) + # Check if there's a component-specific base template (e.g. ENV_APP) + comp_base_key = f"ENV_{pattern.replace('.env.', '').upper()}" + comp_base = "" + if comp_base_key in raw_overrides: + comp_base = raw_overrides[comp_base_key] + # If it's a blob, we don't want to use it as a 'raw template' if it looks like JSON + if comp_base.strip().startswith("{"): + comp_base = "" + + result[pattern] = merge_raw_env(comp_base, overrides) + return result -def create_env_file(conn, file_path: str, env_vars: Dict[str, str]) -> None: +def create_env_file(conn, file_path: str, env_content: str) -> None: """Create .env file with secure permissions (0o600) via run_command (supports sudo)""" - if not env_vars: + if not env_content: return dir_path = os.path.dirname(file_path) if dir_path and dir_path != file_path: # Only mkdir, skip chmod on directory to avoid permission errors run_command(conn, f"mkdir -p {dir_path}") - env_content = "\n".join([f"{k}={v}" for k, v in env_vars.items()]) # Use base64 to avoid shell character/newline mangling issues encoded = base64.b64encode(env_content.encode("utf-8")).decode("utf-8") run_command(conn, f"echo '{encoded}' | base64 -d | tee \"{file_path}\" > /dev/null") @@ -429,8 +436,7 @@ def generate_env_files(conn) -> None: all_env_vars = { k: v for k, v in os.environ.items() - if (k.startswith("ENV_") or k == "ENV" or k == "ENV_BLOB") - and not k.startswith("ENV_FILES_") + if (k.startswith("ENV_") or k == "ENV") and not k.startswith("ENV_FILES_") } env_file_data = detect_environment_secrets() if not env_file_data: @@ -445,19 +451,22 @@ def generate_env_files(conn) -> None: config.GIT_SUBDIR, ) - for pattern, env_vars in env_file_data.items(): + for pattern, env_content 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) + print(f"📝 Creating {file_path} (formatted content preserved)") + create_env_file(conn, file_path, env_content) 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) + # For root mega-file, we also try to use the ENV template + base_template = os.environ.get("ENV") or "" + root_content = merge_raw_env(base_template, root_vars) + print(f"📝 Creating combined root {root_env_path}") + create_env_file(conn, root_env_path, root_content) print("✅ Environment files generated successfully") diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.app b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.app index 4e6cefe..8b6ec2b 100644 --- a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.app +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=8000 DEBUG=true \ No newline at end of file diff --git a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis index f50e56a..6590fc8 100644 --- a/tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis +++ b/tests/integration/generated_envs/auto_dev/.envs/dev/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-yaml-master PORT=6379 \ No newline at end of file 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 index 4e6cefe..8b6ec2b 100644 --- a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.app +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=8000 DEBUG=true \ No newline at end of file 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 index f50e56a..6590fc8 100644 --- a/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.redis +++ b/tests/integration/generated_envs/create_root_agg/.envs/dev/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-yaml-master PORT=6379 \ No newline at end of file diff --git a/tests/integration/generated_envs/custom_path_abs/.env.app b/tests/integration/generated_envs/custom_path_abs/.env.app index 4e6cefe..8b6ec2b 100644 --- a/tests/integration/generated_envs/custom_path_abs/.env.app +++ b/tests/integration/generated_envs/custom_path_abs/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=8000 DEBUG=true \ No newline at end of file diff --git a/tests/integration/generated_envs/custom_path_abs/.env.redis b/tests/integration/generated_envs/custom_path_abs/.env.redis index f50e56a..6590fc8 100644 --- a/tests/integration/generated_envs/custom_path_abs/.env.redis +++ b/tests/integration/generated_envs/custom_path_abs/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-yaml-master PORT=6379 \ No newline at end of file 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 index 4e6cefe..8b6ec2b 100644 --- a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.app +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=8000 DEBUG=true \ No newline at end of file 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 index f50e56a..6590fc8 100644 --- a/tests/integration/generated_envs/custom_path_rel/my_configs/.env.redis +++ b/tests/integration/generated_envs/custom_path_rel/my_configs/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-yaml-master PORT=6379 \ No newline at end of file diff --git a/tests/integration/generated_envs/file_path_secret/.env.app b/tests/integration/generated_envs/file_path_secret/.env.app index 4e6cefe..8b6ec2b 100644 --- a/tests/integration/generated_envs/file_path_secret/.env.app +++ b/tests/integration/generated_envs/file_path_secret/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=8000 DEBUG=true \ No newline at end of file diff --git a/tests/integration/generated_envs/file_path_secret/.env.database b/tests/integration/generated_envs/file_path_secret/.env.database index 729fdf9..6e4aa32 100644 --- a/tests/integration/generated_envs/file_path_secret/.env.database +++ b/tests/integration/generated_envs/file_path_secret/.env.database @@ -1,2 +1,4 @@ +/tmp/pytest-of-horduntech/pytest-67/test_env_exhaustive_file_path_0/jenkins_secret.json + FILE_DB_USER=file-user-admin FILE_DB_PASS=file-secret-pass \ No newline at end of file diff --git a/tests/integration/generated_envs/file_path_secret/.env.redis b/tests/integration/generated_envs/file_path_secret/.env.redis index 815ac72..0acd49e 100644 --- a/tests/integration/generated_envs/file_path_secret/.env.redis +++ b/tests/integration/generated_envs/file_path_secret/.env.redis @@ -1,2 +1,4 @@ +/tmp/pytest-of-horduntech/pytest-67/test_env_exhaustive_file_path_0/jenkins_secret.yaml + HOST=yaml-file-host PORT=6379 \ No newline at end of file diff --git a/tests/integration/generated_envs/file_path_secret/.env.s3 b/tests/integration/generated_envs/file_path_secret/.env.s3 index 5ac318d..fee40f8 100644 --- a/tests/integration/generated_envs/file_path_secret/.env.s3 +++ b/tests/integration/generated_envs/file_path_secret/.env.s3 @@ -1,2 +1,4 @@ +/tmp/pytest-of-horduntech/pytest-67/test_env_exhaustive_file_path_0/jenkins_secret.env + BUCKET=file-bucket REGION=us-east-1 \ No newline at end of file diff --git a/tests/integration/generated_envs/flat_staging/.env.app b/tests/integration/generated_envs/flat_staging/.env.app index 2c8c489..0f40e16 100644 --- a/tests/integration/generated_envs/flat_staging/.env.app +++ b/tests/integration/generated_envs/flat_staging/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=3000 DEBUG=true \ No newline at end of file diff --git a/tests/integration/generated_envs/flat_staging/.env.redis b/tests/integration/generated_envs/flat_staging/.env.redis index f50e56a..6590fc8 100644 --- a/tests/integration/generated_envs/flat_staging/.env.redis +++ b/tests/integration/generated_envs/flat_staging/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-yaml-master PORT=6379 \ No newline at end of file diff --git a/tests/integration/generated_envs/formats_parsing/.env.app b/tests/integration/generated_envs/formats_parsing/.env.app index f7be9b7..e35f6f5 100644 --- a/tests/integration/generated_envs/formats_parsing/.env.app +++ b/tests/integration/generated_envs/formats_parsing/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=9000 DEBUG=true diff --git a/tests/integration/generated_envs/formats_parsing/.env.redis b/tests/integration/generated_envs/formats_parsing/.env.redis index a07d324..902219a 100644 --- a/tests/integration/generated_envs/formats_parsing/.env.redis +++ b/tests/integration/generated_envs/formats_parsing/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-prod-yaml-cluster PORT=6379 \ No newline at end of file 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 index 4e6cefe..8b6ec2b 100644 --- a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.app +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=8000 DEBUG=true \ No newline at end of file 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 index f50e56a..6590fc8 100644 --- a/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.redis +++ b/tests/integration/generated_envs/multi_env_nested/.envs/dev/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-yaml-master PORT=6379 \ No newline at end of file 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 index f7be9b7..e35f6f5 100644 --- a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.app +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=9000 DEBUG=true 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 index a07d324..902219a 100644 --- a/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.redis +++ b/tests/integration/generated_envs/multi_env_nested/.envs/prod/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-prod-yaml-cluster PORT=6379 \ No newline at end of file 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 index 2c8c489..0f40e16 100644 --- a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.app +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.app @@ -1,3 +1,7 @@ +APP_BASE_URL=https://app.com +APP_PORT=8000 +APP_DEBUG=true + BASE_URL=https://app.com PORT=3000 DEBUG=true \ No newline at end of file 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 index f50e56a..6590fc8 100644 --- a/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.redis +++ b/tests/integration/generated_envs/multi_env_nested/.envs/staging/.env.redis @@ -1,2 +1,5 @@ +REDIS_HOST: redis-yaml-master +REDIS_PORT: 6379 + HOST=redis-yaml-master PORT=6379 \ No newline at end of file diff --git a/tests/unit/test_env_manager.py b/tests/unit/test_env_manager.py index 24e8f97..c20d8e0 100644 --- a/tests/unit/test_env_manager.py +++ b/tests/unit/test_env_manager.py @@ -1,4 +1,3 @@ -import json import os from unittest.mock import Mock, patch @@ -69,42 +68,39 @@ def test_root_mega_file_creation(mock_conn, monkeypatch): 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 + merged_content = root_calls[0][0][2] + assert "APP_V1=val1" in merged_content + assert "DB_V2=val2" in merged_content def test_heredoc_escaping(mock_conn): - env_vars = {"KEY": "val$with$dollars"} # We need to mock run_command because create_env_file now uses it - with patch("src.env_manager.run_command") as mock_run_cmd: - create_env_file(mock_conn, ".env", env_vars) + with patch("src.env_manager.run_command") as mock_run: + create_env_file(mock_conn, ".env", "PORT=3000\nDEBUG=true") + # Check that it was called with base64 encoded content found_base64_tee = False - for call in mock_run_cmd.call_args_list: + for call in mock_run.call_args_list: cmd = call[0][1] if "base64 -d" in cmd and "tee" in cmd: found_base64_tee = True - # The content should be base64 encoded - # KEY=val$with$dollars -> S0VZPXZhbCR3aXRoJGRvbGxhcnM= - assert "S0VZPXZhbCR3aXRoJGRvbGxhcnM=" in cmd assert found_base64_tee def test_mixed_blob_and_raw_bucketing(mocker): """Regression test for user's mixed secret setup (toJSON blob + raw block).""" - # Setup: ENV_BLOB has a JSON with an 'ENV' key containing raw vars - mock_json = json.dumps({"ENV": "A=1\nB=2", "ENV_APP": "C=3", "OTHER_UNRELATED": "ignored"}) + # Setup: ENV is a raw block with comments + mock_raw = "# Comments\nA=1\nB=2" # Isolate os.environ to avoid pollution from other tests mocker.patch( - "os.environ", {"ENV_BLOB": mock_json, "ENV_FILES_GENERATE": "true", "ENVIRONMENT": "dev"} + "os.environ", + {"ENV": mock_raw, "ENV_APP": "C=3", "ENV_FILES_GENERATE": "true", "ENVIRONMENT": "dev"}, ) # Patch the config object directly @@ -116,9 +112,10 @@ def test_mixed_blob_and_raw_bucketing(mocker): result = detect_environment_secrets() - # Must correctly parse both from the raw block (A, B) and structured blob (APP_C) + # result is now a dict of strings assert ".env" in result - assert result[".env"]["A"] == "1" - assert result[".env"]["B"] == "2" - assert "APP_C" in result[".env"] + assert "# Comments" in result[".env"] + assert "A=1" in result[".env"] + assert "B=2" in result[".env"] + assert "APP_C=3" in result[".env"] assert "OTHER_UNRELATED" not in result[".env"]