diff --git a/README.md b/README.md index 28f4265..4dfcfba 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,17 @@ metaldeploy --host 1.2.3.4 --user root --ssh-key ~/.ssh/id_rsa --type docker ```yaml - name: Deploy with MetalDeploy uses: OpsGuild/MetalDeploy@v1 + env: + # Set/Override specific variables + ENV_APP_PORT: 9090 with: git_auth_method: token git_token: ${{ secrets.GITHUB_TOKEN }} remote_host: ${{ secrets.REMOTE_HOST }} ssh_key: ${{ secrets.SSH_PRIVATE_KEY }} environment: prod + # Pass all secrets in one go (Zero-Config) + env_blob: ${{ toJSON(secrets) }} ``` ### Advanced Example with Docker @@ -95,7 +100,25 @@ Copy specific files or directories (like `node_modules` or `dist/`) to the serve 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" + copy_artifacts: "dist/:dist, package.json:package.json" +``` + +### 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. +- **Manual Overrides**: Use the standard GitHub Action **`env:`** block to override secrets for specific steps. + +```yaml +- name: Deploy with Overrides + uses: OpsGuild/MetalDeploy@v1 + 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 3a4279d..038efac 100644 --- a/action.yml +++ b/action.yml @@ -102,6 +102,9 @@ 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)' @@ -201,3 +204,5 @@ 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_16-48-08.md b/changelogs/2026-01-29_16-48-08.md index c9cf4f6..a5b04a6 100644 --- a/changelogs/2026-01-29_16-48-08.md +++ b/changelogs/2026-01-29_16-48-08.md @@ -18,4 +18,4 @@ ### Removed - Default value for `env_files_patterns` has been removed, making it an optional input -Note: The changes to the changelog file itself are not explicitly mentioned as they are not directly related to user-facing changes, but rather to the maintenance of the changelog. \ No newline at end of file +Note: The changes to the changelog file itself are not explicitly mentioned as they are not directly related to user-facing changes, but rather to the maintenance of the changelog. diff --git a/changelogs/2026-01-29_17-26-50.md b/changelogs/2026-01-29_17-26-50.md new file mode 100644 index 0000000..d5eb377 --- /dev/null +++ b/changelogs/2026-01-29_17-26-50.md @@ -0,0 +1,20 @@ +# Changelog + +## [Unreleased] + +### Added +- **Bulk Secret Injection**: Ability to pass all repository secrets in one go using `env_blob: ${{ toJSON(secrets) }}`. +- **Manual Overrides**: Support for overriding secrets for specific steps using the standard GitHub Action `env:` block. +- **Strict Filtering**: Only processes keys starting with `ENV_` or the literal `ENV` to ensure security. +- **Relative Path Resolution**: Resolves relative paths against the workspace if not absolute for `COPY_ARTIFACTS`. + +### Changed +- **Environment File Generation**: Updated logic for generating environment files, including support for bulk secret injection and manual overrides. +- **Priority System**: Updated priority system to ensure that specific overrides always take precedence. +- **Copy Artifacts**: Updated `COPY_ARTIFACTS` to handle relative paths and absolute paths correctly. + +### Fixed +- No explicit bug fixes mentioned in the diff, but several changes improve the handling of environment variables and secrets, potentially fixing related issues. + +### Removed +- No features or functionality explicitly removed in the diff, but some configuration options and internal logic have been updated or simplified. \ No newline at end of file diff --git a/docs/env-generation.md b/docs/env-generation.md index 74b5847..31cb40d 100644 --- a/docs/env-generation.md +++ b/docs/env-generation.md @@ -9,104 +9,57 @@ MetalDeploy includes powerful environment file generation capabilities that auto - ✅ **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 - ✅ **Secrets & Variables** - Supports both GitHub Secrets (encrypted) and GitHub Variables (plaintext) -## Configuration - -| Input | Description | Default | -|-------|-------------|---------| -| `env_files_generate` | Enable environment file generation | `false` | -| `env_files_structure` | File structure: `single`, `flat`, `nested`, `auto`, `custom` | `auto` | -| `env_files_path` | Custom path (when `structure=custom`) | - | -| `env_files_patterns` | Comma-separated patterns (`.env.app,.env.database`) | - | -| `env_files_create_root` | Also create a combined `.env` file in project root | `false` | -| `env_files_format` | Format for parsing: `auto`, `env`, `json`, `yaml` | `auto` | - ## How it Works -1. **Discovery**: The action scans environment variables starting with `ENV_` or a literal `ENV` secret. +1. **Discovery**: The action scans environment variables starting with `ENV_` or a literal `ENV`. 2. **Bucketing**: - - **Literal `ENV`**: Treated as a non-prefixed global blob. Its contents go directly into the `.env` file without any modifications. + - **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_...`**: Automatically identifies prefixes matching common environments (`PROD`, `STAGING`, `DEV`, `TEST`) **plus** your current `environment` input. -3. **Generation**: Files are generated on the remote server with secure permissions. + - **`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. -## Secret Naming Convention +## Priority System (Last one wins) -### Literal Global Blob (No Prefix) -If you want to dump a list of variables without any prefixing or component logic, use the literal secret name `ENV`. +The priority system ensures that specific overrides always take precedence: -```bash -# GitHub Secret: ENV -PORT=8080 -DEBUG=true -``` -**Result**: `.env` contains `PORT=8080` and `DEBUG=true`. +1. **`env_blob` Contents** (Lowest) + Everything inside your `env_blob` (like `toJSON(secrets)`) is 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) + Secrets matching your `environment` input (e.g. `ENV_PROD_...`) win last. -### Individual Variables +## Workflow Structure Example -```bash -# Base (environment-agnostic) -ENV_APP_DEBUG=false -ENV_APP_SECRET_KEY=base-key -ENV_DATABASE_HOST=localhost - -# Environment-specific (higher priority) -# If environment input is 'prod', this will override base values -ENV_PROD_APP_SECRET_KEY=prod-secret -ENV_PROD_DATABASE_HOST=prod-host -``` - -### All-in-One Variables - -```bash -# Environment-specific all-in-one (highest priority) -ENV_PROD_APP=" -DEBUG=false -SECRET_KEY=prod-secret -DATABASE_URL=postgresql://prod-host:5432/db -" - -# Base all-in-one (fallback) -ENV_APP=" -DEBUG=true -SECRET_KEY=dev-secret -" -``` - -## Priority System - -The priority system ensures proper variable overriding (last one wins): - -1. **Literal `ENV`** (Lowest priority - Base Layer) -2. **Base Component secrets** (e.g., `ENV_APP_...`) -3. **Base All-in-one secrets** (e.g., `ENV_APP="..."`) -4. **Env-specific Component secrets** (e.g., `ENV_PROD_APP_...`) -5. **Env-specific All-in-one secrets** (Highest priority - `ENV_PROD_APP="..."`) - -## Usage Examples - -### Example 1: Custom Environment Names -MetalDeploy automatically supports your custom environment names for prefixes. +Here is exactly how the `env:` block looks alongside the `with:` block in a GitHub Action: ```yaml -- uses: ./ +- name: Deploy to Staging + uses: ./ + env: + # MANUAL OVERRIDES GO HERE + ENV_APP_PORT: 9090 # This wins over any other PORT setting with: env_files_generate: 'true' - environment: 'qa' # Custom environment name + env_blob: ${{ toJSON(secrets) }} + environment: 'staging' + remote_host: ${{ secrets.REMOTE_HOST }} + # ... other inputs ... ``` -**Secrets**: -- `ENV_APP_PORT=8080` (Base) -- `ENV_QA_APP_PORT=9090` (Overrides base because environment is 'qa') -**Result**: `.env.app` will have `PORT=9090`. +## Usage Example: Bulk Secret Injection + +For the simplest setup, pass all secrets in one go: -### Example 2: Single Mode ```yaml - uses: ./ with: env_files_generate: 'true' - env_files_structure: 'single' + env_blob: ${{ toJSON(secrets) }} ``` -**Result**: Creates `/project/.env` with all variables merged. Component variables are prefixed (e.g., `APP_PORT`, `DB_HOST`) to prevent collisions, while literal `ENV` variables remain un-prefixed. + +**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/config.py b/src/config.py index 6d07e11..45abaef 100644 --- a/src/config.py +++ b/src/config.py @@ -70,11 +70,16 @@ def get_env(name, default=None): # Build Artifacts artifacts = get_env("COPY_ARTIFACTS") self.COPY_ARTIFACTS = [] + workspace = get_env("GITHUB_WORKSPACE", ".") if artifacts: for item in artifacts.split(","): if ":" in item: local, remote = item.split(":", 1) - self.COPY_ARTIFACTS.append((local.strip(), remote.strip())) + local_path = local.strip() + # Resolve relative path against workspace if not absolute + if not os.path.isabs(local_path): + local_path = os.path.abspath(os.path.join(workspace, local_path)) + self.COPY_ARTIFACTS.append((local_path, remote.strip())) # Global state for temporary files self.SSH_KEY_PATH = None diff --git a/src/env_manager.py b/src/env_manager.py index b96c801..6d47817 100644 --- a/src/env_manager.py +++ b/src/env_manager.py @@ -126,9 +126,12 @@ def detect_file_patterns( if var_name.startswith(prefix): suffix = var_name[len(prefix) :] break - else: + elif var_name.startswith("ENV_"): # Strip ENV_ suffix = var_name[4:] + else: + # This is a direct variable (no prefix) from a blob, it goes to the primary .env + continue # Clean up leading underscores if any (e.g. _REDIS) suffix = suffix.lstrip("_") @@ -203,8 +206,8 @@ def merge_env_vars_by_priority( # 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_"): + # Skip internal configuration and raw blobs + if k.startswith("ENV_FILES_") or k == "ENV" or k == "ENV_BLOB": continue # Skip environment-specific ones @@ -295,32 +298,67 @@ def merge_env_vars_by_priority( 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_") or k == "ENV") and not k.startswith("ENV_FILES_") - } - if not all_env_vars: + # 1. Parse Blobs (base layer) + # This supports env_blob: ${{ toJSON(secrets) }} + blob_content = os.environ.get("ENV") or os.environ.get("ENV_BLOB") + working_vars = {} + special_global_base = {} + + if blob_content: + parsed_blob = parse_all_in_one_secret(blob_content, config.ENV_FILES_FORMAT) + if parsed_blob: + # If the blob content ITSELF is a dictionary (e.g. JSON/YAML) + 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) + elif k.startswith("ENV_"): + # This is a structured secret (e.g. ENV_APP_...) + working_vars[k] = v + # ALL OTHER KEYS are ignored (strictly no global fallout) + + # 2. Overwrite with real environment variables (higher priority) + # This handles individual secrets mapped in the workflow 'env:' block + for k, v in os.environ.items(): + if k.startswith("ENV_") and not k.startswith("ENV_FILES_"): + working_vars[k] = v + elif k == "ENV" or k == "ENV_BLOB": + # If ENV is passed directly as a string (not in a bulk blob) + # though usually it comes through the blob logic above + global_parsed = parse_all_in_one_secret(v, config.ENV_FILES_FORMAT) + if global_parsed: + # If v was a JSON blob like {"ENV_APP"...}, we already handled it. + # If v was a raw string "KEY=VAL", it goes to global base. + is_structured_json = any(key.startswith("ENV_") for key in global_parsed.keys()) + if is_structured_json: + # Re-verify and update working_vars if it was a structured blob + for gk, gv in global_parsed.items(): + if gk.startswith("ENV_"): + working_vars[gk] = gv + else: + special_global_base.update(global_parsed) + + if not working_vars and not special_global_base: return {} structure = config.ENV_FILES_STRUCTURE if structure == "single": patterns = [".env"] else: - patterns = detect_file_patterns(all_env_vars, structure, config.ENVIRONMENT) + patterns = detect_file_patterns(working_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 = {} - if "ENV" in all_env_vars: - # Treat ENV as a global blob that goes into .env (or first pattern) without prefixing - parsed = parse_all_in_one_secret(all_env_vars["ENV"], config.ENV_FILES_FORMAT) - if parsed: - target_file = patterns[0] if patterns else ".env" - result[target_file] = parsed.copy() + # 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(all_env_vars, config.ENVIRONMENT, pattern) + merged_vars = merge_env_vars_by_priority(working_vars, config.ENVIRONMENT, pattern) if merged_vars: if pattern in result: result[pattern].update(merged_vars) @@ -351,7 +389,8 @@ 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") and not k.startswith("ENV_FILES_") + if (k.startswith("ENV_") or k == "ENV" or k == "ENV_BLOB") + and not k.startswith("ENV_FILES_") } env_file_data = detect_environment_secrets() if not env_file_data: