├── requirements.txt ├── images └── n8n-porter.drawio.png ├── .gitignore ├── credential_schemas ├── httpHeaderAuth.json ├── telegramApi.json ├── openAiApi.json └── postgres.json ├── servers.yaml.example ├── LICENSE ├── credentials.yaml.example ├── credential_schemas.py ├── README.md └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dotenv 2 | requests 3 | PyYAML 4 | colorama -------------------------------------------------------------------------------- /images/n8n-porter.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dataprospectors-at/n8n-porter/HEAD/images/n8n-porter.drawio.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | workflows/ 3 | .conda/ 4 | .venv/ 5 | .vscode/ 6 | credentials.yaml 7 | __pycache__/ 8 | .DS_Store 9 | data/ 10 | servers.yaml 11 | resource_mapping.json 12 | .cursor -------------------------------------------------------------------------------- /credential_schemas/httpHeaderAuth.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "type": "object", 4 | "properties": { 5 | "name": { 6 | "type": "string" 7 | }, 8 | "value": { 9 | "type": "string" 10 | } 11 | }, 12 | "required": [] 13 | } -------------------------------------------------------------------------------- /credential_schemas/telegramApi.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "type": "object", 4 | "properties": { 5 | "accessToken": { 6 | "type": "string" 7 | }, 8 | "baseUrl": { 9 | "type": "string" 10 | } 11 | }, 12 | "required": [] 13 | } -------------------------------------------------------------------------------- /credential_schemas/openAiApi.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "type": "object", 4 | "properties": { 5 | "apiKey": { 6 | "type": "string" 7 | }, 8 | "organizationId": { 9 | "type": "string" 10 | }, 11 | "url": { 12 | "type": "string" 13 | } 14 | }, 15 | "required": [ 16 | "apiKey" 17 | ] 18 | } -------------------------------------------------------------------------------- /servers.yaml.example: -------------------------------------------------------------------------------- 1 | servers: 2 | cloud: 3 | name: "N8N Cloud Instance" 4 | api_key: "your_cloud_api_key_here" 5 | url: "https://your.n8n.cloud/" 6 | supports_projects: true 7 | 8 | local: 9 | name: "Local Development" 10 | api_key: "your_local_api_key_here" 11 | url: "http://localhost:5678/" 12 | supports_projects: false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 dataprospectors-at 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /credentials.yaml.example: -------------------------------------------------------------------------------- 1 | # Template for storing production and development credentials 2 | 3 | environments: 4 | production: 5 | name: "Production Environment" 6 | postfix: "Prod" 7 | credentials: 8 | telegram_bot: 9 | type: telegramApi 10 | name: "Telegram Bot" 11 | data: 12 | accessToken: "your_prod_telegram_token" 13 | baseUrl: "https://api.telegram.org" # optional 14 | 15 | telegram_feedback_bot: 16 | type: telegramApi 17 | name: "Feedback Bot" 18 | data: 19 | accessToken: "your_prod_feedback_token" 20 | baseUrl: "https://api.telegram.org" # optional 21 | 22 | postgres_main: 23 | type: postgres 24 | name: "Main Database" 25 | data: 26 | host: "your_prod_host" 27 | database: "your_prod_db" 28 | user: "your_prod_user" 29 | password: "your_prod_password" 30 | port: 5432 31 | ssl: "disable" # allow, disable, or require 32 | sshTunnel: false 33 | 34 | openai: 35 | type: openAiApi 36 | name: "OpenAI API" 37 | data: 38 | apiKey: "your_prod_api_key" 39 | organizationId: "your_prod_org_id" # optional 40 | url: "https://api.openai.com" # optional 41 | 42 | web_api: 43 | type: httpHeaderAuth 44 | name: "Web API Key" 45 | data: 46 | name: "X-API-Key" 47 | value: "your_prod_api_key" 48 | 49 | development: 50 | name: "Development Environment" 51 | postfix: "Dev" 52 | credentials: 53 | telegram_bot: 54 | type: telegramApi 55 | name: "Telegram Bot" 56 | data: 57 | accessToken: "your_dev_telegram_token" 58 | baseUrl: "https://api.telegram.org" # optional 59 | 60 | telegram_feedback_bot: 61 | type: telegramApi 62 | name: "Feedback Bot" 63 | data: 64 | accessToken: "your_dev_feedback_token" 65 | baseUrl: "https://api.telegram.org" # optional 66 | 67 | postgres_main: 68 | type: postgres 69 | name: "Main Database" 70 | data: 71 | host: "your_dev_host" 72 | database: "your_dev_db" 73 | user: "your_dev_user" 74 | password: "your_dev_password" 75 | port: 5432 76 | ssl: "disable" # allow, disable, or require 77 | sshTunnel: false 78 | 79 | openai: 80 | type: openAiApi 81 | name: "OpenAI API" 82 | data: 83 | apiKey: "your_dev_api_key" 84 | organizationId: "your_dev_org_id" # optional 85 | url: "https://api.openai.com" # optional 86 | 87 | web_api: 88 | type: httpHeaderAuth 89 | name: "Web API Key" 90 | data: 91 | name: "X-API-Key" 92 | value: "your_dev_api_key" 93 | 94 | # String replacements between environments 95 | replacements: 96 | # Each replacement has a name and environment-specific values 97 | web_service_url: 98 | description: "The base URL of the web service" 99 | values: 100 | production: "https://api.example.com" 101 | development: "http://127.0.0.1" 102 | staging: "https://staging.api.example.com" # example of adding a new environment 103 | 104 | web_service_alt_url: 105 | description: "Alternative URL for the web service" 106 | values: 107 | production: "https://service.example.com" 108 | development: "http://127.0.0.1" 109 | staging: "https://staging.service.example.com" # example of adding a new environment 110 | 111 | feedback_channel: 112 | description: "Telegram channel for feedback" 113 | values: 114 | production: "feedback-channel" 115 | development: "feedback-channel-dev" 116 | staging: "feedback-channel-staging" # example of adding a new environment -------------------------------------------------------------------------------- /credential_schemas/postgres.json: -------------------------------------------------------------------------------- 1 | { 2 | "additionalProperties": false, 3 | "type": "object", 4 | "properties": { 5 | "host": { 6 | "type": "string" 7 | }, 8 | "database": { 9 | "type": "string" 10 | }, 11 | "user": { 12 | "type": "string" 13 | }, 14 | "password": { 15 | "type": "string" 16 | }, 17 | "maxConnections": { 18 | "type": "number" 19 | }, 20 | "allowUnauthorizedCerts": { 21 | "type": "boolean" 22 | }, 23 | "ssl": { 24 | "type": "string", 25 | "enum": [ 26 | "allow", 27 | "disable", 28 | "require" 29 | ] 30 | }, 31 | "port": { 32 | "type": "number" 33 | }, 34 | "sshTunnel": { 35 | "type": "boolean" 36 | }, 37 | "sshAuthenticateWith": { 38 | "type": "string", 39 | "enum": [ 40 | "password", 41 | "privateKey" 42 | ] 43 | }, 44 | "sshHost": { 45 | "type": "string" 46 | }, 47 | "sshPort": { 48 | "type": "number" 49 | }, 50 | "sshUser": { 51 | "type": "string" 52 | }, 53 | "sshPassword": { 54 | "type": "string" 55 | }, 56 | "privateKey": { 57 | "type": "string" 58 | }, 59 | "passphrase": { 60 | "type": "string" 61 | } 62 | }, 63 | "allOf": [ 64 | { 65 | "if": { 66 | "properties": { 67 | "allowUnauthorizedCerts": { 68 | "enum": [ 69 | "" 70 | ] 71 | } 72 | } 73 | }, 74 | "then": { 75 | "allOf": [ 76 | { 77 | "required": [ 78 | "ssl" 79 | ] 80 | } 81 | ] 82 | }, 83 | "else": { 84 | "allOf": [ 85 | { 86 | "not": { 87 | "required": [ 88 | "ssl" 89 | ] 90 | } 91 | } 92 | ] 93 | } 94 | }, 95 | { 96 | "if": { 97 | "properties": { 98 | "sshTunnel": { 99 | "enum": [ 100 | true 101 | ] 102 | } 103 | } 104 | }, 105 | "then": { 106 | "allOf": [ 107 | { 108 | "required": [ 109 | "sshAuthenticateWith" 110 | ] 111 | }, 112 | { 113 | "required": [ 114 | "sshHost" 115 | ] 116 | }, 117 | { 118 | "required": [ 119 | "sshPort" 120 | ] 121 | }, 122 | { 123 | "required": [ 124 | "sshUser" 125 | ] 126 | }, 127 | { 128 | "required": [ 129 | "sshPassword" 130 | ] 131 | }, 132 | { 133 | "required": [ 134 | "privateKey" 135 | ] 136 | }, 137 | { 138 | "required": [ 139 | "passphrase" 140 | ] 141 | } 142 | ] 143 | }, 144 | "else": { 145 | "allOf": [ 146 | { 147 | "not": { 148 | "required": [ 149 | "sshAuthenticateWith" 150 | ] 151 | } 152 | }, 153 | { 154 | "not": { 155 | "required": [ 156 | "sshHost" 157 | ] 158 | } 159 | }, 160 | { 161 | "not": { 162 | "required": [ 163 | "sshPort" 164 | ] 165 | } 166 | }, 167 | { 168 | "not": { 169 | "required": [ 170 | "sshUser" 171 | ] 172 | } 173 | }, 174 | { 175 | "not": { 176 | "required": [ 177 | "sshPassword" 178 | ] 179 | } 180 | }, 181 | { 182 | "not": { 183 | "required": [ 184 | "privateKey" 185 | ] 186 | } 187 | }, 188 | { 189 | "not": { 190 | "required": [ 191 | "passphrase" 192 | ] 193 | } 194 | } 195 | ] 196 | } 197 | } 198 | ], 199 | "required": [] 200 | } -------------------------------------------------------------------------------- /credential_schemas.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | import yaml 5 | from typing import List, Dict 6 | from pathlib import Path 7 | 8 | def print_success(message: str) -> None: 9 | """Print a success message in green""" 10 | print(f"\033[92m{message}\033[0m") 11 | 12 | def print_error(message: str) -> None: 13 | """Print an error message in red""" 14 | print(f"\033[91m{message}\033[0m") 15 | 16 | def print_info(message: str) -> None: 17 | """Print an info message in blue""" 18 | print(f"\033[94m{message}\033[0m") 19 | 20 | def ensure_directory_exists(path: str) -> None: 21 | """Ensure that a directory exists, create it if it doesn't""" 22 | Path(path).mkdir(parents=True, exist_ok=True) 23 | 24 | # Known credential types from our workflows 25 | CREDENTIAL_TYPES = [ 26 | "telegramApi", 27 | "postgres", 28 | "openAiApi", 29 | "httpHeaderAuth" 30 | ] 31 | 32 | def load_servers() -> Dict: 33 | """Load server configurations from servers.yaml""" 34 | try: 35 | with open('servers.yaml', 'r') as f: 36 | config = yaml.safe_load(f) 37 | return config.get('servers', {}) 38 | except Exception as e: 39 | print_error(f"Error loading servers.yaml: {str(e)}") 40 | return {} 41 | 42 | def select_server(servers: Dict) -> Dict: 43 | """Let user select a server from the available options""" 44 | if not servers: 45 | print_error("No servers found in servers.yaml") 46 | exit(1) 47 | 48 | print("\nAvailable servers:") 49 | for idx, (server_id, server) in enumerate(servers.items(), 1): 50 | print(f"{idx}. {server['name']} ({server_id})") 51 | 52 | while True: 53 | try: 54 | choice = int(input("\nSelect a server (enter number): ")) 55 | if 1 <= choice <= len(servers): 56 | server_id = list(servers.keys())[choice - 1] 57 | return servers[server_id] 58 | print_error("Invalid selection. Please try again.") 59 | except ValueError: 60 | print_error("Please enter a valid number.") 61 | 62 | def get_credential_schemas(api_key: str, base_url: str) -> None: 63 | """Fetch and store credential schemas for known credential types""" 64 | headers = {"X-N8N-API-KEY": api_key} 65 | 66 | print("\nFetching credential schemas...") 67 | ensure_directory_exists("credential_schemas") 68 | 69 | for cred_type in CREDENTIAL_TYPES: 70 | try: 71 | response = requests.get( 72 | f"{base_url}/api/v1/credentials/schema/{cred_type}", 73 | headers=headers 74 | ) 75 | 76 | if response.status_code == 200: 77 | schema = response.json() 78 | 79 | # Save schema to file 80 | schema_file = os.path.join("credential_schemas", f"{cred_type}.json") 81 | with open(schema_file, 'w', encoding='utf-8') as f: 82 | json.dump(schema, f, indent=2) 83 | print_success(f" ✓ Saved schema for {cred_type}") 84 | else: 85 | print_error(f" ✗ Failed to get schema for {cred_type}: {response.status_code}") 86 | 87 | except Exception as e: 88 | print_error(f" ✗ Error fetching schema for {cred_type}: {str(e)}") 89 | 90 | def get_schema(cred_type: str) -> Dict: 91 | """Get a credential schema from file""" 92 | schema_file = os.path.join("credential_schemas", f"{cred_type}.json") 93 | try: 94 | with open(schema_file, 'r', encoding='utf-8') as f: 95 | return json.load(f) 96 | except Exception as e: 97 | print_error(f"Error loading schema for {cred_type}: {str(e)}") 98 | return {} 99 | 100 | def list_available_schemas() -> List[str]: 101 | """List all available credential schemas""" 102 | schema_dir = "credential_schemas" 103 | if not os.path.exists(schema_dir): 104 | return [] 105 | 106 | return [f.replace('.json', '') for f in os.listdir(schema_dir) 107 | if f.endswith('.json')] 108 | 109 | def generate_credential_example(schema: Dict, cred_type: str) -> Dict: 110 | """Generate an example credential configuration based on the schema""" 111 | # Create a credential ID based on the type 112 | cred_id = cred_type.lower() 113 | 114 | example = { 115 | "environments": { 116 | "production": { 117 | "name": "Production Environment", 118 | "postfix": "Prod", 119 | "credentials": { 120 | cred_id: { 121 | "type": cred_type, 122 | "name": f"Example {cred_type} Credential", 123 | "data": {} 124 | } 125 | } 126 | }, 127 | "development": { 128 | "name": "Development Environment", 129 | "postfix": "Dev", 130 | "credentials": { 131 | cred_id: { 132 | "type": cred_type, 133 | "name": f"Example {cred_type} Credential", 134 | "data": {} 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | # Extract all fields from schema properties 142 | if "properties" in schema: 143 | for field, props in schema["properties"].items(): 144 | # Get field type and description 145 | field_type = props.get("type", "string") 146 | description = props.get("description", "") 147 | 148 | # Generate example value based on field type 149 | if field_type == "string": 150 | example_value = "example_string_value" 151 | elif field_type == "number": 152 | example_value = 0 153 | elif field_type == "boolean": 154 | example_value = False 155 | elif field_type == "array": 156 | example_value = [] 157 | elif field_type == "object": 158 | example_value = {} 159 | else: 160 | example_value = "example_value" 161 | 162 | # Add field to both environments 163 | example["environments"]["production"]["credentials"][cred_id]["data"][field] = example_value 164 | example["environments"]["development"]["credentials"][cred_id]["data"][field] = example_value 165 | 166 | # Add description as comment if available 167 | if description: 168 | example["environments"]["production"]["credentials"][cred_id]["data"][f"# {field}"] = description 169 | example["environments"]["development"]["credentials"][cred_id]["data"][f"# {field}"] = description 170 | 171 | return example 172 | 173 | def show_credential_examples() -> None: 174 | """Show example credential configurations for available schemas""" 175 | schemas = list_available_schemas() 176 | if not schemas: 177 | print_error("No credential schemas found. Please download schemas first.") 178 | return 179 | 180 | print("\nAvailable credential types:") 181 | for idx, schema in enumerate(schemas, 1): 182 | print(f"{idx}. {schema}") 183 | 184 | while True: 185 | try: 186 | choice = int(input("\nSelect a credential type to view example (enter number): ")) 187 | if 1 <= choice <= len(schemas): 188 | cred_type = schemas[choice - 1] 189 | schema = get_schema(cred_type) 190 | if schema: 191 | example = generate_credential_example(schema, cred_type) 192 | print_info(f"\nExample credential configuration for {cred_type}:") 193 | print("\nCopy this into your credentials.yaml:") 194 | print("---") 195 | print(yaml.dump(example, default_flow_style=False, sort_keys=False)) 196 | return 197 | print_error("Invalid selection. Please try again.") 198 | except ValueError: 199 | print_error("Please enter a valid number.") 200 | 201 | def main_menu() -> None: 202 | """Display and handle the main menu""" 203 | while True: 204 | print("\nCredential Schema Management") 205 | print("1. Download credential schemas from server") 206 | print("2. View example credential configurations") 207 | print("3. Exit") 208 | 209 | try: 210 | choice = int(input("\nSelect an option (enter number): ")) 211 | if choice == 1: 212 | servers = load_servers() 213 | selected_server = select_server(servers) 214 | api_key = selected_server['api_key'] 215 | base_url = selected_server['url'] 216 | print(f"\nUsing server: {selected_server['name']}") 217 | get_credential_schemas(api_key, base_url) 218 | elif choice == 2: 219 | show_credential_examples() 220 | elif choice == 3: 221 | print_info("Goodbye!") 222 | break 223 | else: 224 | print_error("Invalid selection. Please try again.") 225 | except ValueError: 226 | print_error("Please enter a valid number.") 227 | 228 | if __name__ == "__main__": 229 | main_menu() 230 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # n8n porter 2 | 3 | ## A N8N Workflow & Credentials Migration Tool 4 | 5 | A Python-based tool for managing n8n workflows across different environments. This tool helps you backup, restore, and clean up workflows and credentials in n8n instances. 6 | 7 | - Export projects + credentials 8 | - Restore projects 9 | - to different servers 10 | - using different sets of credentials 11 | - replacing strings on import/export 12 | 13 | #### How it works 14 | ![N8N Porter Tool](images/n8n-porter.drawio.png) 15 | 16 | ## Features 17 | 18 | - **Backup Workflows**: Save all workflows from an n8n instance to local storage 19 | - **Restore Workflows**: Restore workflows to a target n8n instance with proper dependency handling 20 | - **Cleanup Resources**: Safely delete all workflows and credentials from an instance 21 | - **Smart Dependency Resolution**: Automatically detects and handles workflow dependencies 22 | - **Environment Management**: Supports multiple environments with proper credential mapping and postfix handling 23 | - **Project Support**: Works with both project-based and non-project n8n instances 24 | - **Resource Mapping**: Maintains a mapping of resources across instances for better tracking 25 | - **String Replacements**: Handles environment-specific value replacements in workflows 26 | 27 | #### Version differences 28 | > ⚠️ **IMPORTANT: N8N Version Differences** 29 | > 30 | > The tool handles both Community and Enterprise versions of n8n 31 | > When using the tool: 32 | > - Community version: All operations affect the entire server 33 | > - Enterprise version: You must select a project for operations 34 | > 35 | > Make sure to set `supports_projects: false` in your `servers.yaml` for Community instances. 36 | 37 | ## Prerequisites 38 | 39 | - Python 3.6 or higher 40 | - Required Python packages: 41 | - requests 42 | - pyyaml 43 | - python-dotenv 44 | 45 | ## Credential Schema Management 46 | 47 | The tool includes a credential schema management feature that helps you create and maintain your `credentials.yaml` file. This feature allows you to: 48 | 49 | 1. **Download Credential Schemas** 50 | - Fetch the latest credential schemas from your n8n instance 51 | - Supports all credential types used in your workflows 52 | - Schemas are saved locally for offline reference 53 | 54 | 2. **View Example Configurations** 55 | - Generate example credential configurations based on schemas 56 | - Shows all required fields and their types 57 | - Includes field descriptions from the schema 58 | - Outputs in the correct format for `credentials.yaml` 59 | 60 | To use the credential schema management: 61 | 62 | ```bash 63 | python credential_schemas.py 64 | ``` 65 | 66 | The tool will present you with options to: 67 | 1. Download credential schemas from a server 68 | 2. View example credential configurations 69 | 3. Exit 70 | 71 | ### Example Output 72 | 73 | When viewing a credential example, you'll see output like this: 74 | 75 | ```yaml 76 | environments: 77 | production: 78 | name: Production Environment 79 | postfix: Prod 80 | credentials: 81 | telegramapi: 82 | type: telegramApi 83 | name: Example telegramApi Credential 84 | data: 85 | # accessToken: The Telegram Bot API access token 86 | accessToken: example_string_value 87 | # baseUrl: The base URL for the Telegram API 88 | baseUrl: example_string_value 89 | # additionalParameters: Any additional parameters to include in requests 90 | additionalParameters: {} 91 | development: 92 | name: Development Environment 93 | postfix: Dev 94 | credentials: 95 | telegramapi: 96 | type: telegramApi 97 | name: Example telegramApi Credential 98 | data: 99 | # accessToken: The Telegram Bot API access token 100 | accessToken: example_string_value 101 | # baseUrl: The base URL for the Telegram API 102 | baseUrl: example_string_value 103 | # additionalParameters: Any additional parameters to include in requests 104 | additionalParameters: {} 105 | ``` 106 | 107 | This helps you: 108 | - Understand what fields are required for each credential type 109 | - See the correct structure for your `credentials.yaml` 110 | - Get field descriptions and types 111 | - Generate proper examples for both production and development environments 112 | 113 | ## Setup 114 | 115 | 1. Clone the repository 116 | 2. Install required packages: 117 | ```bash 118 | pip install -r requirements.txt 119 | ``` 120 | 121 | 3. Create configuration files: 122 | 123 | ### servers.yaml 124 | Create a `servers.yaml` file with your n8n instance configurations: 125 | 126 | ```yaml 127 | servers: 128 | local: 129 | name: "Local n8n" 130 | url: "http://localhost:5678" 131 | api_key: "your-api-key" 132 | supports_projects: true 133 | 134 | production: 135 | name: "Production n8n" 136 | url: "https://n8n.your-domain.com" 137 | api_key: "your-api-key" 138 | supports_projects: true 139 | ``` 140 | 141 | ### credentials.yaml 142 | Create a `credentials.yaml` file to manage environment-specific credentials and replacements: 143 | 144 | ```yaml 145 | environments: 146 | production: 147 | name: "Production Environment" 148 | postfix: "Prod" # Optional postfix for credential names 149 | credentials: 150 | telegram_bot: 151 | type: telegramApi 152 | name: "Telegram Bot" 153 | data: 154 | accessToken: "your-prod-token" 155 | baseUrl: "https://api.telegram.org" 156 | 157 | development: 158 | name: "Development Environment" 159 | postfix: "Dev" 160 | credentials: 161 | telegram_bot: 162 | type: telegramApi 163 | name: "Telegram Bot" 164 | data: 165 | accessToken: "your-dev-token" 166 | baseUrl: "https://api.telegram.org" 167 | 168 | # String replacements between environments 169 | replacements: 170 | web_service_url: 171 | description: "The base URL of the web service" 172 | values: 173 | production: "https://api.example.com" 174 | development: "http://127.0.0.1" 175 | staging: "https://staging.api.example.com" 176 | ``` 177 | 178 | ## Usage 179 | 180 | Run the tool: 181 | ```bash 182 | python main.py 183 | ``` 184 | 185 | The tool will present you with three options: 186 | 187 | 1. **Backup workflows** 188 | - Select source server 189 | - Select project (if supported) 190 | - Workflows will be saved to `data/backup_[project]_[timestamp]/workflows/` 191 | 192 | 2. **Restore workflows** 193 | - Select target server 194 | - Select project (if supported) 195 | - Choose target environment (production/development/staging) 196 | - Select backup to restore from 197 | - Tool will: 198 | - Create credentials with proper environment postfixes 199 | - Analyze workflow dependencies 200 | - Create workflows in correct order 201 | - Update all references and mappings 202 | - Apply environment-specific string replacements 203 | 204 | 3. **Delete tracked workflows and credentials** 205 | - Select target server 206 | - Confirm deletion 207 | - Tool will safely remove ONLY the workflows and credentials that were automatically created 208 | - Only affects resources tracked in resource_mapping.json 209 | - Manually created resources are not affected 210 | - If no tracked resources are found, the operation is skipped 211 | 212 | ## Environment Management 213 | 214 | ### Credential Postfixes 215 | - Each environment can have an optional postfix (e.g., "Prod", "Dev", "Staging") 216 | - Postfixes are automatically handled in credential names 217 | - Existing postfixes are removed before adding new ones 218 | - Example: "Telegram Bot" becomes "Telegram Bot Prod" in production 219 | 220 | ### String Replacements 221 | - Each replacement has: 222 | - A descriptive name 223 | - A description of its purpose 224 | - Environment-specific values 225 | - Supports multiple environments 226 | - Values are replaced based on target environment 227 | - Common replacements include: 228 | - Web service URLs 229 | - API endpoints 230 | - Channel names 231 | - Database connections 232 | 233 | ## Resource Mapping 234 | 235 | The tool maintains a `resource_mapping.json` file that tracks: 236 | - Workflow IDs across instances 237 | - Credential IDs across instances 238 | - Project IDs (if supported) 239 | 240 | This mapping helps ensure proper reference handling during restore operations. 241 | 242 | ## Tips and Best Practices 243 | 244 | 1. **Backup Before Restore** 245 | - Always backup your target instance before restoring workflows 246 | - This allows you to rollback if something goes wrong 247 | 248 | 2. **Credential Management** 249 | - Keep your `credentials.yaml` file secure and never commit it to version control 250 | - Use environment variables for sensitive values 251 | - Use consistent naming across environments 252 | - Leverage postfixes for environment distinction 253 | 254 | 3. **String Replacements** 255 | - Use descriptive names for replacements 256 | - Document the purpose of each replacement 257 | - Keep environment values organized 258 | - Test replacements in development first 259 | 260 | 4. **Dependency Handling** 261 | - The tool automatically detects workflow dependencies 262 | - Workflows are created in the correct order 263 | - Circular dependencies are detected and prevented 264 | 265 | 5. **Project Support** 266 | - For project-based instances, make sure to select the correct project 267 | - The tool handles both project and non-project instances automatically 268 | 269 | 6. **Error Handling** 270 | - The tool provides detailed error messages 271 | - Failed operations are logged 272 | - Partial restores are cleaned up automatically 273 | 274 | ## Troubleshooting 275 | 276 | 1. **API Key Issues** 277 | - Verify your API keys in `servers.yaml` 278 | - Ensure the API key has necessary permissions 279 | 280 | 2. **Connection Problems** 281 | - Check if the n8n instance is accessible 282 | - Verify the URL in `servers.yaml` 283 | 284 | 3. **Credential Mapping** 285 | - If credentials aren't mapping correctly, check the naming in `credentials.yaml` 286 | - Ensure credential types match between environments 287 | - Verify postfix handling is working as expected 288 | 289 | 4. **String Replacements** 290 | - If values aren't being replaced, check the replacements configuration 291 | - Verify the target environment exists in the replacements 292 | - Ensure the values match exactly in the workflows 293 | 294 | 5. **Workflow Dependencies** 295 | - If a workflow fails to restore, check its dependencies 296 | - Verify all dependent workflows are present in the backup 297 | 298 | ## Contributing 299 | 300 | Feel free to submit issues and enhancement requests! -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional, Tuple 2 | import requests 3 | import os 4 | from dotenv import load_dotenv 5 | import yaml 6 | import json 7 | from pathlib import Path 8 | from datetime import datetime 9 | import sys 10 | 11 | def print_summary(message: str) -> None: 12 | """Print a summary header in blue color. 13 | 14 | Args: 15 | message (str): The message to display as a summary header. 16 | """ 17 | print(f"\n\033[94m=== {message} ===\033[0m") 18 | 19 | def print_success(message: str) -> None: 20 | """Print a success message in green color. 21 | 22 | Args: 23 | message (str): The success message to display. 24 | """ 25 | print(f"\033[92m✓ {message}\033[0m") 26 | 27 | def print_error(message: str) -> None: 28 | """Print an error message in red color. 29 | 30 | Args: 31 | message (str): The error message to display. 32 | """ 33 | print(f"\033[91m✗ {message}\033[0m") 34 | 35 | def get_all_projects(api_key: str, base_url: str) -> List[Dict]: 36 | """Get all available projects from n8n instance. 37 | 38 | Args: 39 | api_key (str): The API key for authentication. 40 | base_url (str): The base URL of the n8n instance. 41 | 42 | Returns: 43 | List[Dict]: List of project dictionaries containing project information. 44 | """ 45 | headers = {"X-N8N-API-KEY": api_key} 46 | try: 47 | response = requests.get(f"{base_url}/api/v1/projects", headers=headers) 48 | if response.status_code == 200: 49 | return response.json().get('data', []) 50 | elif response.status_code == 403: 51 | print_error("Project listing not allowed, probably on local instance.") 52 | return [] 53 | else: 54 | print_error(f"Failed to get projects: {response.status_code}") 55 | return [] 56 | except Exception as e: 57 | print_error(f"Error getting projects: {str(e)}") 58 | return [] 59 | 60 | def create_project(api_key: str, base_url: str, name: str) -> Optional[str]: 61 | """Create a new project in n8n instance. 62 | 63 | Args: 64 | api_key (str): The API key for authentication. 65 | base_url (str): The base URL of the n8n instance. 66 | name (str): Name of the project to create. 67 | 68 | Returns: 69 | Optional[str]: The ID of the created project if successful, None otherwise. 70 | """ 71 | headers = { 72 | "X-N8N-API-KEY": api_key, 73 | "Content-Type": "application/json" 74 | } 75 | try: 76 | response = requests.post( 77 | f"{base_url}/api/v1/projects", 78 | headers=headers, 79 | json={"name": name} 80 | ) 81 | 82 | if response.status_code in [200, 201]: 83 | project_id = response.json().get('id') 84 | print_success(f"Created project: {name}") 85 | return project_id 86 | else: 87 | print_error(f"Failed to create project: {response.status_code}") 88 | return None 89 | except Exception as e: 90 | print_error(f"Error creating project: {str(e)}") 91 | return None 92 | 93 | def create_credential(api_key: str, base_url: str, credential_data: Dict, credential_type: str, env_postfix: str = "") -> Optional[str]: 94 | """Create a new credential in n8n instance. 95 | 96 | Args: 97 | api_key (str): The API key for authentication. 98 | base_url (str): The base URL of the n8n instance. 99 | credential_data (Dict): Dictionary containing credential information. 100 | credential_type (str): Type of the credential to create. 101 | env_postfix (str): Environment postfix to append to credential name. 102 | 103 | Returns: 104 | Optional[str]: The ID of the created credential if successful, None otherwise. 105 | """ 106 | url = f"{base_url}/api/v1/credentials" 107 | headers = { 108 | "X-N8N-API-KEY": api_key, 109 | "Content-Type": "application/json" 110 | } 111 | 112 | # Get all possible postfixes from environments 113 | try: 114 | with open('credentials.yaml', 'r') as f: 115 | creds_config = yaml.safe_load(f) 116 | all_postfixes = [ 117 | env.get('postfix', '').strip() 118 | for env in creds_config.get('environments', {}).values() 119 | if env.get('postfix', '').strip() 120 | ] 121 | except Exception as e: 122 | print_error(f"Warning: Could not load postfixes from credentials.yaml: {str(e)}") 123 | all_postfixes = [] 124 | 125 | # Clean the credential name and remove any existing postfix 126 | name = credential_data["name"].strip() 127 | for postfix in all_postfixes: 128 | if name.endswith(f" {postfix}"): 129 | name = name[:-len(f" {postfix}")].strip() 130 | break 131 | 132 | # Add new postfix if provided 133 | if env_postfix: 134 | name = f"{name} {env_postfix}" 135 | 136 | payload = { 137 | "name": name, 138 | "type": credential_type, 139 | "data": credential_data["data"] 140 | } 141 | 142 | try: 143 | response = requests.post(url, headers=headers, json=payload) 144 | 145 | if response.status_code == 200: 146 | credential = response.json() 147 | save_resource_mapping(base_url, 'credentials', credential['id'], credential['name']) 148 | print_success(f"Created credential: {credential['name']}") 149 | return credential['id'] 150 | else: 151 | print_error(f"Failed to create credential {name}: {response.text}") 152 | return None 153 | 154 | except Exception as e: 155 | print_error(f"Error creating credential {name}: {str(e)}") 156 | return None 157 | 158 | def get_workflows(api_key: str, base_url: str, project_id: Optional[str]) -> List[Dict]: 159 | """Get all workflows from a project or instance. 160 | 161 | Args: 162 | api_key (str): The API key for authentication. 163 | base_url (str): The base URL of the n8n instance. 164 | project_id (Optional[str]): ID of the project to get workflows from. If None, gets all workflows. 165 | 166 | Returns: 167 | List[Dict]: List of workflow dictionaries containing workflow information. 168 | """ 169 | headers = {"X-N8N-API-KEY": api_key} 170 | try: 171 | params = {"projectId": project_id} if project_id else {} 172 | response = requests.get( 173 | f"{base_url}/api/v1/workflows", 174 | headers=headers, 175 | params=params 176 | ) 177 | if response.status_code == 200: 178 | return response.json().get('data', []) 179 | else: 180 | print_error(f"Failed to get workflows: {response.status_code}") 181 | return [] 182 | except Exception as e: 183 | print_error(f"Error getting workflows: {str(e)}") 184 | return [] 185 | 186 | def get_environment_replacements(creds_config: Dict, env_type: str) -> Dict[str, str]: 187 | """Get string replacements for the target environment. 188 | 189 | Args: 190 | creds_config (Dict): Configuration dictionary containing replacement mappings. 191 | env_type (str): Type of environment (e.g., 'production', 'development', 'staging'). 192 | 193 | Returns: 194 | Dict[str, str]: Dictionary mapping old values to new values based on environment. 195 | """ 196 | replacements = {} 197 | replacements_config = creds_config.get('replacements', {}) 198 | 199 | for replacement_name, replacement_data in replacements_config.items(): 200 | values = replacement_data.get('values', {}) 201 | if env_type in values: 202 | # For each environment value, we need to replace all other environment values 203 | target_value = values[env_type] 204 | for other_env, other_value in values.items(): 205 | if other_env != env_type: 206 | replacements[other_value] = target_value 207 | 208 | return replacements 209 | 210 | def create_workflow(api_key: str, base_url: str, workflow_data: Dict, project_id: str, 211 | credential_mapping: Dict, sf_id_mapping: Dict = None, env_type: str = 'dev', 212 | supports_projects: bool = True, env_postfix: str = "") -> Optional[str]: 213 | """Create a new workflow in n8n instance. 214 | 215 | Args: 216 | api_key (str): The API key for authentication. 217 | base_url (str): The base URL of the n8n instance. 218 | workflow_data (Dict): Dictionary containing workflow information. 219 | project_id (str): ID of the project to create the workflow in. 220 | credential_mapping (Dict): Dictionary mapping credential names to IDs. 221 | sf_id_mapping (Dict, optional): Dictionary mapping subflow IDs. Defaults to None. 222 | env_type (str, optional): Type of environment. Defaults to 'dev'. 223 | supports_projects (bool, optional): Whether the instance supports projects. Defaults to True. 224 | env_postfix (str): Environment postfix to append to credential names. 225 | 226 | Returns: 227 | Optional[str]: The ID of the created workflow if successful, None otherwise. 228 | """ 229 | headers = { 230 | "X-N8N-API-KEY": api_key, 231 | "Content-Type": "application/json" 232 | } 233 | 234 | workflow_payload = json.loads(json.dumps(workflow_data)) 235 | 236 | try: 237 | with open('credentials.yaml', 'r') as f: 238 | creds_config = yaml.safe_load(f) 239 | replacements = get_environment_replacements(creds_config, env_type) 240 | 241 | workflow_str = json.dumps(workflow_payload) 242 | 243 | for old_value, new_value in replacements.items(): 244 | workflow_str = workflow_str.replace(old_value, new_value) 245 | 246 | workflow_payload = json.loads(workflow_str) 247 | 248 | except Exception as e: 249 | print_error(f"Warning: Could not apply string replacements: {str(e)}") 250 | workflow_payload = workflow_data 251 | 252 | workflow_payload.update({ 253 | "settings": { 254 | "saveExecutionProgress": workflow_payload.get('settings', {}).get('saveExecutionProgress', True), 255 | "saveManualExecutions": workflow_payload.get('settings', {}).get('saveManualExecutions', True), 256 | "saveDataErrorExecution": workflow_payload.get('settings', {}).get('saveDataErrorExecution', 'all'), 257 | "executionTimeout": workflow_payload.get('settings', {}).get('executionTimeout', 3600), 258 | "errorWorkflow": workflow_payload.get('settings', {}).get('errorWorkflow', '') 259 | } 260 | }) 261 | 262 | if 'nodes' in workflow_payload: 263 | for node in workflow_payload['nodes']: 264 | if 'credentials' in node: 265 | for cred_type, cred_data in node['credentials'].items(): 266 | old_name = cred_data.get('name', '') 267 | # Remove any existing postfix and whitespace 268 | base_name = old_name.split(' ')[0] if ' ' in old_name else old_name 269 | 270 | matching_cred = None 271 | for cred_key, new_id in credential_mapping.items(): 272 | cred_base_name = cred_key.split(' ')[0] if ' ' in cred_key else cred_key 273 | if (cred_base_name.lower() == base_name.lower() or 274 | cred_base_name.replace('_', ' ').lower() == base_name.lower()): 275 | matching_cred = new_id 276 | break 277 | 278 | if matching_cred: 279 | cred_data['id'] = matching_cred 280 | else: 281 | print_error(f"No matching credential found for: {old_name}") 282 | 283 | if node.get('type') == 'n8n-nodes-base.executeWorkflow' and sf_id_mapping: 284 | params = node.get('parameters', {}) 285 | old_workflow_id = params.get('workflowId') 286 | 287 | if isinstance(old_workflow_id, str) and old_workflow_id in sf_id_mapping: 288 | params['workflowId'] = sf_id_mapping[old_workflow_id] 289 | print_success(f"Updated subworkflow reference in node '{node.get('name')}': {old_workflow_id} → {sf_id_mapping[old_workflow_id]}") 290 | elif isinstance(old_workflow_id, dict): 291 | old_id = old_workflow_id.get('value') 292 | if old_id and old_id in sf_id_mapping: 293 | old_workflow_id['value'] = sf_id_mapping[old_id] 294 | print_success(f"Updated subworkflow reference in node '{node.get('name')}': {old_id} → {sf_id_mapping[old_id]}") 295 | if 'cachedResultName' in old_workflow_id: 296 | old_workflow_id['cachedResultName'] = old_workflow_id['cachedResultName'].split(' ')[0] 297 | 298 | elif node.get('type') == '@n8n/n8n-nodes-langchain.toolWorkflow' and sf_id_mapping: 299 | params = node.get('parameters', {}) 300 | old_workflow_id = params.get('workflowId') 301 | 302 | if isinstance(old_workflow_id, dict): 303 | old_id = old_workflow_id.get('value') 304 | if old_id and old_id in sf_id_mapping: 305 | old_workflow_id['value'] = sf_id_mapping[old_id] 306 | print_success(f"Updated subworkflow reference in tool node '{node.get('name')}': {old_id} → {sf_id_mapping[old_id]}") 307 | if 'cachedResultName' in old_workflow_id: 308 | old_workflow_id['cachedResultName'] = old_workflow_id['cachedResultName'].split(' ')[0] 309 | 310 | create_payload = { 311 | "name": workflow_payload['name'], 312 | "nodes": workflow_payload['nodes'], 313 | "connections": workflow_payload['connections'], 314 | "settings": workflow_payload.get('settings', {}) 315 | } 316 | 317 | try: 318 | response = requests.post( 319 | f"{base_url}/api/v1/workflows", 320 | headers=headers, 321 | json=create_payload 322 | ) 323 | 324 | if response.status_code != 200: 325 | error_detail = response.json() if response.text else "No error details available" 326 | print_error(f"Failed to create workflow {workflow_payload['name']}: Status {response.status_code}") 327 | print_error(f"Error details: {error_detail}") 328 | return None 329 | 330 | workflow_id = response.json().get('id') 331 | 332 | if supports_projects: 333 | transfer_response = requests.put( 334 | f"{base_url}/api/v1/workflows/{workflow_id}/transfer", 335 | headers=headers, 336 | json={"destinationProjectId": project_id} 337 | ) 338 | 339 | if transfer_response.status_code not in [200, 204]: 340 | error_detail = transfer_response.json() if transfer_response.text else "No error details available" 341 | print_error(f"Failed to transfer workflow {workflow_payload['name']}: Status {transfer_response.status_code}") 342 | print_error(f"Error details: {error_detail}") 343 | try: 344 | requests.delete(f"{base_url}/api/v1/workflows/{workflow_id}", headers=headers) 345 | print_error("Cleaned up partially created workflow") 346 | except Exception as cleanup_error: 347 | print_error(f"Error during cleanup: {str(cleanup_error)}") 348 | return None 349 | 350 | save_resource_mapping(base_url, 'workflows', workflow_id, workflow_payload['name']) 351 | print_success(f"Created workflow: {workflow_payload['name']}") 352 | return workflow_id 353 | 354 | except Exception as e: 355 | print_error(f"Error creating workflow {workflow_payload['name']}") 356 | print_error(f"Error details: {str(e)}") 357 | return None 358 | 359 | def save_mapping_info(filename: str, mapping_data: Dict) -> None: 360 | """Save mapping information to a JSON file. 361 | 362 | Args: 363 | filename (str): Name of the file to save mappings to. 364 | mapping_data (Dict): Dictionary containing mapping information. 365 | """ 366 | try: 367 | with open(filename, 'w', encoding='utf-8') as f: 368 | json.dump(mapping_data, f, indent=2) 369 | print_success(f"Saved mapping information to {filename}") 370 | except Exception as e: 371 | print_error(f"Failed to save mapping information: {str(e)}") 372 | 373 | def ensure_directory_exists(path: str) -> None: 374 | """Ensure that a directory exists, create it if it doesn't. 375 | 376 | Args: 377 | path (str): Path of the directory to ensure exists. 378 | """ 379 | Path(path).mkdir(parents=True, exist_ok=True) 380 | 381 | def save_workflow(workflow: Dict, base_path: str, subfolder: str) -> None: 382 | """Save a workflow to the specified path and subfolder. 383 | 384 | Args: 385 | workflow (Dict): Dictionary containing workflow information. 386 | base_path (str): Base path to save the workflow. 387 | subfolder (str): Subfolder within the base path to save the workflow. 388 | """ 389 | filepath = Path(base_path) / subfolder / f"{workflow['name']}_{workflow['id']}.json" 390 | try: 391 | ensure_directory_exists(filepath.parent) 392 | with open(filepath, 'w', encoding='utf-8') as f: 393 | json.dump(workflow, f, indent=2, ensure_ascii=False) 394 | except Exception as e: 395 | print_error(f"Failed to save workflow {workflow['name']}: {str(e)}") 396 | 397 | def select_project(projects: List[Dict]) -> Optional[Dict]: 398 | """Let user select a project from the list. 399 | 400 | Args: 401 | projects (List[Dict]): List of project dictionaries to choose from. 402 | 403 | Returns: 404 | Optional[Dict]: Selected project dictionary if successful, None otherwise. 405 | """ 406 | while True: 407 | try: 408 | choice = int(input("\nSelect a project (number): ")) - 1 409 | if 0 <= choice < len(projects): 410 | return projects[choice] 411 | print_error("Invalid choice. Please try again.") 412 | except ValueError: 413 | print_error("Please enter a number.") 414 | return None 415 | 416 | def perform_backup(api_key: str, base_url: str, project: Dict, supports_projects: bool, server_name: str) -> None: 417 | """Backup all workflows from an n8n instance. 418 | 419 | Args: 420 | api_key (str): The API key for authentication. 421 | base_url (str): The base URL of the n8n instance. 422 | project (Dict): Dictionary containing project information. 423 | supports_projects (bool): Whether the instance supports projects. 424 | server_name (str): Name of the server from servers.yaml. 425 | """ 426 | print("\nFetching workflows...") 427 | workflows = get_workflows(api_key, base_url, project.get('id')) 428 | 429 | if not workflows: 430 | print_error("No workflows found to backup") 431 | return 432 | print_success(f"Found {len(workflows)} workflows") 433 | 434 | timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') 435 | # Sanitize server name for use in filename 436 | sanitized_server_name = "".join(c for c in server_name if c.isalnum() or c in (' ', '-', '_')).strip().replace(' ', '_') 437 | backup_path = Path("data") / f"backup_{sanitized_server_name}_{project['name']}_{timestamp}" 438 | print(f"\nCreating backup directory: {backup_path}") 439 | ensure_directory_exists(backup_path) 440 | 441 | print("\nSaving workflows...") 442 | for i, workflow in enumerate(workflows, 1): 443 | save_workflow(workflow, backup_path, "workflows") 444 | print(f"Progress: {i}/{len(workflows)} workflows saved", end='\r') 445 | 446 | print("\n") 447 | print_success(f"Backup complete! {len(workflows)} workflows saved to {backup_path}") 448 | 449 | def analyze_workflow_dependencies(workflow: Dict) -> List[str]: 450 | """Analyze a workflow to find all its dependencies (referenced workflows). 451 | 452 | Args: 453 | workflow (Dict): Dictionary containing workflow information. 454 | 455 | Returns: 456 | List[str]: List of workflow IDs that this workflow depends on. 457 | """ 458 | dependencies = [] 459 | 460 | if 'nodes' not in workflow: 461 | return dependencies 462 | 463 | for node in workflow['nodes']: 464 | # Check for executeWorkflow nodes 465 | if node.get('type') == 'n8n-nodes-base.executeWorkflow': 466 | params = node.get('parameters', {}) 467 | workflow_id = params.get('workflowId') 468 | if isinstance(workflow_id, str): 469 | dependencies.append(workflow_id) 470 | elif isinstance(workflow_id, dict): 471 | old_id = workflow_id.get('value') 472 | if old_id: 473 | dependencies.append(old_id) 474 | 475 | # Check for toolWorkflow nodes 476 | elif node.get('type') == '@n8n/n8n-nodes-langchain.toolWorkflow': 477 | params = node.get('parameters', {}) 478 | workflow_id = params.get('workflowId') 479 | if isinstance(workflow_id, dict): 480 | old_id = workflow_id.get('value') 481 | if old_id: 482 | dependencies.append(old_id) 483 | 484 | return dependencies 485 | 486 | def build_dependency_graph(workflows: List[Dict]) -> Dict[str, List[str]]: 487 | """Build a graph of workflow dependencies. 488 | 489 | Args: 490 | workflows (List[Dict]): List of all workflows. 491 | 492 | Returns: 493 | Dict[str, List[str]]: Dictionary mapping workflow IDs to their dependencies. 494 | """ 495 | graph = {} 496 | for workflow in workflows: 497 | workflow_id = workflow['id'] 498 | graph[workflow_id] = analyze_workflow_dependencies(workflow) 499 | return graph 500 | 501 | def get_workflow_order(graph: Dict[str, List[str]]) -> List[str]: 502 | """Determine the order in which workflows should be created based on dependencies. 503 | 504 | Args: 505 | graph (Dict[str, List[str]]): Dictionary mapping workflow IDs to their dependencies. 506 | 507 | Returns: 508 | List[str]: List of workflow IDs in the order they should be created. 509 | """ 510 | # Create a copy of the graph to modify 511 | remaining = graph.copy() 512 | order = [] 513 | 514 | while remaining: 515 | # Find workflows with no remaining dependencies 516 | ready = [workflow_id for workflow_id, deps in remaining.items() 517 | if not any(dep in remaining for dep in deps)] 518 | 519 | if not ready: 520 | # If no ready workflows but still have remaining, we have a cycle 521 | raise ValueError("Circular dependency detected in workflows") 522 | 523 | # Add ready workflows to order and remove them from remaining 524 | order.extend(ready) 525 | for workflow_id in ready: 526 | del remaining[workflow_id] 527 | 528 | return order 529 | 530 | def perform_restore(api_key: str, base_url: str, project: Dict, supports_projects: bool, target_env: str) -> None: 531 | """Restore workflows to an n8n instance. 532 | 533 | Args: 534 | api_key (str): The API key for authentication. 535 | base_url (str): The base URL of the n8n instance. 536 | project (Dict): Dictionary containing project information. 537 | supports_projects (bool): Whether the instance supports projects. 538 | target_env (str): Target environment to restore to ('production' or 'development'). 539 | """ 540 | stats = { 541 | 'workflows_created': 0, 542 | 'workflows_failed': 0, 543 | 'credentials_created': 0, 544 | 'credentials_failed': 0 545 | } 546 | 547 | print("\nLoading credentials configuration...") 548 | try: 549 | with open('credentials.yaml', 'r') as f: 550 | creds_config = yaml.safe_load(f) 551 | print_success("Credentials configuration loaded") 552 | except Exception as e: 553 | print_error(f"Error loading credentials.yaml: {str(e)}") 554 | return 555 | 556 | print_summary("Restore Process") 557 | print(f"Target Server: {base_url}") 558 | print(f"Project: {project['name']}") 559 | print(f"Target environment: {target_env}") 560 | print(f"Project support: {'Yes' if supports_projects else 'No'}") 561 | 562 | data_dir = Path("data") 563 | if not data_dir.exists(): 564 | print_error("No backups found. Data directory doesn't exist.") 565 | return 566 | 567 | backups = [d for d in data_dir.iterdir() if d.is_dir() and d.name.startswith("backup_")] 568 | if not backups: 569 | print_error("No backups found in data directory.") 570 | return 571 | 572 | print("\nAvailable backups:") 573 | for i, backup in enumerate(backups, 1): 574 | print(f"{i}. {backup.name}") 575 | 576 | while True: 577 | try: 578 | choice = int(input("\nSelect backup to restore (number): ")) - 1 579 | if 0 <= choice < len(backups): 580 | backup_dir = backups[choice] 581 | break 582 | print_error("Invalid choice. Please try again.") 583 | except ValueError: 584 | print_error("Please enter a number.") 585 | 586 | workflow_dir = backup_dir / "workflows" 587 | if not workflow_dir.exists(): 588 | print_error(f"No workflows found in backup {backup_dir.name}") 589 | return 590 | 591 | print("\nLoading workflows from backup...") 592 | workflows = [] 593 | for workflow_file in workflow_dir.glob("*.json"): 594 | try: 595 | with open(workflow_file, 'r', encoding='utf-8') as f: 596 | workflow = json.load(f) 597 | workflows.append(workflow) 598 | except Exception as e: 599 | print_error(f"Error loading workflow {workflow_file.name}: {str(e)}") 600 | 601 | if not workflows: 602 | print_error("No workflows found in backup") 603 | return 604 | 605 | print_success(f"Found {len(workflows)} workflows to restore") 606 | 607 | print("\nCreating credentials...") 608 | credential_mapping = {} 609 | 610 | if creds_config and 'environments' in creds_config and target_env in creds_config['environments']: 611 | env_data = creds_config['environments'][target_env] 612 | env_credentials = env_data.get('credentials', {}) 613 | env_name = env_data.get('name', target_env.title()) 614 | env_postfix = env_data.get('postfix', '').strip() 615 | 616 | print_success(f"Using environment: {env_name}") 617 | if env_postfix: 618 | print_success(f"Environment postfix: {env_postfix}") 619 | 620 | for cred_key, cred_data in env_credentials.items(): 621 | try: 622 | credential_data = { 623 | "name": cred_data['name'], 624 | "data": cred_data['data'] 625 | } 626 | 627 | new_cred_id = create_credential(api_key, base_url, credential_data, cred_data['type'], env_postfix) 628 | 629 | if new_cred_id: 630 | credential_mapping[cred_key] = new_cred_id 631 | credential_mapping[cred_data['name']] = new_cred_id 632 | stats['credentials_created'] += 1 633 | print_success(f"Created credential: {cred_data['name']}{' ' + env_postfix if env_postfix else ''}") 634 | else: 635 | stats['credentials_failed'] += 1 636 | print_error(f"Failed to create credential: {cred_data['name']}{' ' + env_postfix if env_postfix else ''}") 637 | 638 | except Exception as e: 639 | stats['credentials_failed'] += 1 640 | print_error(f"Error processing credential {cred_data['name']}: {str(e)}") 641 | else: 642 | print_error(f"No credentials found for environment: {target_env}") 643 | return 644 | 645 | print("\nAnalyzing workflow dependencies...") 646 | try: 647 | dependency_graph = build_dependency_graph(workflows) 648 | workflow_order = get_workflow_order(dependency_graph) 649 | print_success(f"Found {len(workflow_order)} workflows to create in correct order") 650 | except ValueError as e: 651 | print_error(f"Error analyzing dependencies: {str(e)}") 652 | return 653 | 654 | print("\nCreating workflows in dependency order...") 655 | workflow_mapping = {} 656 | for workflow_id in workflow_order: 657 | # Find the workflow data 658 | workflow_data = next(w for w in workflows if w['id'] == workflow_id) 659 | 660 | # Create the workflow 661 | new_id = create_workflow(api_key, base_url, workflow_data, project.get('id'), 662 | credential_mapping, workflow_mapping, target_env, supports_projects, 663 | env_postfix) 664 | 665 | if new_id: 666 | stats['workflows_created'] += 1 667 | workflow_mapping[workflow_id] = new_id 668 | print_success(f"Created workflow: {workflow_data['name']}") 669 | else: 670 | stats['workflows_failed'] += 1 671 | print_error(f"Failed to create workflow: {workflow_data['name']}") 672 | 673 | print_summary("Restore Complete") 674 | print(f"Credentials: {stats['credentials_created']} created, {stats['credentials_failed']} failed") 675 | print(f"Workflows: {stats['workflows_created']} created, {stats['workflows_failed']} failed") 676 | 677 | def load_server_config() -> Dict: 678 | """Load server configuration from servers.yaml file. 679 | 680 | Returns: 681 | Dict: Dictionary containing server configuration. 682 | 683 | Raises: 684 | SystemExit: If servers.yaml is not found or cannot be loaded. 685 | """ 686 | try: 687 | with open('servers.yaml', 'r') as f: 688 | return yaml.safe_load(f) 689 | except FileNotFoundError: 690 | print_error("servers.yaml not found. Please copy servers.yaml.example to servers.yaml and configure your servers.") 691 | sys.exit(1) 692 | except Exception as e: 693 | print_error(f"Error loading servers.yaml: {str(e)}") 694 | sys.exit(1) 695 | 696 | def print_menu(title: str, options: List[str]) -> int: 697 | """Display a menu and return user's choice. 698 | 699 | Args: 700 | title (str): Title of the menu. 701 | options (List[str]): List of menu options. 702 | 703 | Returns: 704 | int: Index of the selected option. 705 | """ 706 | print_summary(title) 707 | for i, option in enumerate(options, 1): 708 | print(f"{i}. {option}") 709 | 710 | while True: 711 | try: 712 | choice = int(input("\nSelect option (number): ")) - 1 713 | if 0 <= choice < len(options): 714 | return choice 715 | print_error("Invalid choice. Please try again.") 716 | except ValueError: 717 | print_error("Please enter a number.") 718 | 719 | def select_server(servers: Dict) -> Tuple[str, Dict]: 720 | """Let user select a server from the list. 721 | 722 | Args: 723 | servers (Dict): Dictionary containing server configurations. 724 | 725 | Returns: 726 | Tuple[str, Dict]: Tuple containing selected server name and configuration. 727 | """ 728 | server_names = list(servers['servers'].keys()) 729 | server_options = [f"{servers['servers'][name]['name']} ({name})" 730 | for name in server_names] 731 | 732 | choice = print_menu("Select Server", server_options) 733 | selected_name = server_names[choice] 734 | return selected_name, servers['servers'][selected_name] 735 | 736 | def get_or_create_project(api_key: str, base_url: str, server_config: Dict) -> Optional[Dict]: 737 | """Get existing project or handle non-project server. 738 | 739 | Args: 740 | api_key (str): The API key for authentication. 741 | base_url (str): The base URL of the n8n instance. 742 | server_config (Dict): Dictionary containing server configuration. 743 | 744 | Returns: 745 | Optional[Dict]: Dictionary containing project information if successful, None otherwise. 746 | """ 747 | if not server_config.get('supports_projects', False): 748 | return { 749 | 'id': None, 750 | 'name': 'default' 751 | } 752 | 753 | projects = get_all_projects(api_key, base_url) 754 | if not projects: 755 | print_error("No projects found") 756 | return None 757 | 758 | project_options = [f"{project['name']} (ID: {project['id']})" 759 | for project in projects] 760 | 761 | choice = print_menu("Select Project", project_options) 762 | return projects[choice] 763 | 764 | def validate_configs() -> bool: 765 | """Validate configuration files exist and have correct structure. 766 | 767 | Returns: 768 | bool: True if all required configuration files exist, False otherwise. 769 | """ 770 | required_files = ['servers.yaml', 'credentials.yaml'] 771 | for file in required_files: 772 | if not Path(file).exists(): 773 | print_error(f"Missing {file}. Please copy {file}.example and configure it.") 774 | return False 775 | return True 776 | 777 | def test_api_connection(api_key: str, base_url: str) -> bool: 778 | """Test if the API key is valid and the connection works. 779 | 780 | Args: 781 | api_key (str): The API key to test. 782 | base_url (str): The base URL of the n8n instance. 783 | 784 | Returns: 785 | bool: True if the connection is successful, False otherwise. 786 | """ 787 | headers = {"X-N8N-API-KEY": api_key} 788 | try: 789 | # Try to get workflows, which is a core API endpoint 790 | response = requests.get(f"{base_url}/api/v1/workflows", headers=headers) 791 | if response.status_code == 200: 792 | print_success("API connection successful") 793 | return True 794 | else: 795 | print_error(f"API connection failed: {response.status_code}") 796 | print_error(f"Error details: {response.text}") 797 | return False 798 | except requests.exceptions.ConnectionError: 799 | print_error("Failed to connect to n8n instance. Is it running?") 800 | return False 801 | except Exception as e: 802 | print_error(f"Error testing API connection: {str(e)}") 803 | return False 804 | 805 | def main() -> None: 806 | """Main entry point for the n8n workflow migration tool.""" 807 | while True: 808 | print_summary("N8N Workflow Migration Tool") 809 | 810 | if not validate_configs(): 811 | return 812 | 813 | try: 814 | with open('servers.yaml', 'r') as f: 815 | servers = yaml.safe_load(f) 816 | except Exception as e: 817 | print_error(f"Error loading servers.yaml: {str(e)}") 818 | return 819 | 820 | print("\nWhat would you like to do?") 821 | print("1. Backup workflows") 822 | print("2. Restore workflows") 823 | print("3. Delete all workflows and credentials") 824 | print("4. Exit") 825 | choice = input("Enter your choice (1-4): ") 826 | 827 | if choice == '4': 828 | print_success("Goodbye!") 829 | return 830 | 831 | if choice not in ['1', '2', '3']: 832 | print_error("Invalid choice. Please select 1, 2, 3, or 4") 833 | continue 834 | 835 | server_name, server_config = select_server(servers) 836 | api_key = server_config['api_key'] 837 | base_url = server_config['url'] 838 | supports_projects = server_config.get('supports_projects', False) 839 | 840 | print_success(f"Selected server: {server_config['name']}") 841 | print_success(f"Projects supported: {'Yes' if supports_projects else 'No'}") 842 | 843 | # Test API connection before proceeding 844 | if not test_api_connection(api_key, base_url): 845 | print_error("Failed to connect to n8n instance. Please check your API key and make sure the instance is running.") 846 | continue 847 | 848 | if supports_projects: 849 | print_summary("Project Selection") 850 | project = get_or_create_project(api_key, base_url, server_config) 851 | else: 852 | print_summary("Using Default Project") 853 | project = {'id': None, 'name': 'default'} 854 | print_success("Using default project (no project support)") 855 | 856 | if project is None: 857 | continue 858 | 859 | if choice == '1': 860 | print_summary(f"Starting Backup from {server_config['name']}") 861 | print(f"Project: {project['name']}") 862 | perform_backup(api_key, base_url, project, supports_projects, server_name) 863 | elif choice == '2': 864 | print_summary(f"Starting Restore to {server_config['name']}") 865 | print(f"Target Project: {project['name']}") 866 | 867 | env_choice = print_menu("Select Target Environment", [ 868 | "Production", 869 | "Development" 870 | ]) 871 | target_env = ["production", "development"][env_choice] 872 | 873 | print_success(f"Using {target_env} credentials") 874 | perform_restore(api_key, base_url, project, supports_projects, target_env) 875 | elif choice == '3': 876 | perform_cleanup(api_key, base_url, project) 877 | 878 | # Wait for user to press any key before continuing 879 | input("\nPress Enter to continue...") 880 | 881 | def perform_cleanup(api_key: str, base_url: str, project: Dict) -> None: 882 | """Delete all workflows and credentials from an instance. 883 | 884 | Args: 885 | api_key (str): The API key for authentication. 886 | base_url (str): The base URL of the n8n instance. 887 | project (Dict): Dictionary containing project information. 888 | """ 889 | print("\nWarning: This will delete ONLY the workflows and credentials that were automatically created") 890 | print("and are tracked in resource_mapping.json. Any manually created resources will not be affected.") 891 | confirm = input("Are you sure you want to proceed? (yes/no): ") 892 | 893 | if confirm.lower() != 'yes': 894 | print("Operation cancelled.") 895 | return 896 | 897 | stats = { 898 | 'workflows_deleted': 0, 899 | 'workflows_failed': 0, 900 | 'credentials_deleted': 0, 901 | 'credentials_failed': 0 902 | } 903 | 904 | resources = get_instance_resources(base_url) 905 | 906 | if not resources['workflows'] and not resources['credentials']: 907 | print("\nNo tracked resources found in resource_mapping.json for this instance.") 908 | print("This means either:") 909 | print("1. No resources were created using this tool yet") 910 | print("2. All tracked resources have already been deleted") 911 | print("3. Resources were created manually and are not tracked") 912 | return 913 | 914 | print("\nDeleting credentials...") 915 | for cred_id, cred_name in resources['credentials'].items(): 916 | try: 917 | delete_credential(api_key, base_url, cred_id) 918 | stats['credentials_deleted'] += 1 919 | print_success(f"Deleted credential: {cred_name}") 920 | except Exception as e: 921 | stats['credentials_failed'] += 1 922 | print_error(f"Failed to delete credential {cred_name}: {str(e)}") 923 | 924 | print("\nDeleting workflows...") 925 | for workflow_id, workflow_name in resources['workflows'].items(): 926 | try: 927 | delete_workflow(api_key, base_url, workflow_id) 928 | stats['workflows_deleted'] += 1 929 | print_success(f"Deleted workflow: {workflow_name}") 930 | except Exception as e: 931 | stats['workflows_failed'] += 1 932 | print_error(f"Failed to delete workflow {workflow_name}: {str(e)}") 933 | 934 | if project.get('id') and project['id'] in resources['projects']: 935 | try: 936 | delete_project(api_key, base_url, project['id']) 937 | print_success(f"Deleted project: {project['name']}") 938 | except Exception as e: 939 | print_error(f"Failed to delete project {project['name']}: {str(e)}") 940 | 941 | print("\nCleanup Summary:") 942 | print(f"Credentials deleted: {stats['credentials_deleted']}") 943 | print(f"Credentials failed to delete: {stats['credentials_failed']}") 944 | print(f"Workflows deleted: {stats['workflows_deleted']}") 945 | print(f"Workflows failed to delete: {stats['workflows_failed']}") 946 | print("\nNote: Only resources tracked in resource_mapping.json were affected.") 947 | 948 | def get_workflow_by_id(api_key: str, base_url: str, workflow_id: str) -> Optional[Dict]: 949 | """Get a specific workflow by its ID. 950 | 951 | Args: 952 | api_key (str): The API key for authentication. 953 | base_url (str): The base URL of the n8n instance. 954 | workflow_id (str): ID of the workflow to retrieve. 955 | 956 | Returns: 957 | Optional[Dict]: Dictionary containing workflow information if successful, None otherwise. 958 | """ 959 | headers = {"X-N8N-API-KEY": api_key} 960 | try: 961 | response = requests.get( 962 | f"{base_url}/api/v1/workflows/{workflow_id}", 963 | headers=headers 964 | ) 965 | if response.status_code == 200: 966 | return response.json() 967 | else: 968 | print_error(f"Failed to get workflow {workflow_id}: {response.status_code}") 969 | return None 970 | except Exception as e: 971 | print_error(f"Error getting workflow {workflow_id}: {str(e)}") 972 | return None 973 | 974 | def remove_resource_mapping(instance_url: str, resource_type: str, resource_id: str) -> None: 975 | """Remove resource mapping from local storage. 976 | 977 | Args: 978 | instance_url (str): URL of the n8n instance. 979 | resource_type (str): Type of resource (workflows, credentials, projects). 980 | resource_id (str): ID of the resource to remove. 981 | """ 982 | storage_file = 'resource_mapping.json' 983 | try: 984 | if os.path.exists(storage_file): 985 | with open(storage_file, 'r') as f: 986 | mappings = json.load(f) 987 | 988 | if (instance_url in mappings and 989 | resource_type in mappings[instance_url] and 990 | resource_id in mappings[instance_url][resource_type]): 991 | del mappings[instance_url][resource_type][resource_id] 992 | 993 | with open(storage_file, 'w') as f: 994 | json.dump(mappings, f, indent=2) 995 | 996 | except Exception as e: 997 | print_error(f"Failed to remove resource mapping: {str(e)}") 998 | 999 | def delete_workflow(api_key: str, base_url: str, workflow_id: str) -> None: 1000 | """Delete a workflow by ID. 1001 | 1002 | Args: 1003 | api_key (str): The API key for authentication. 1004 | base_url (str): The base URL of the n8n instance. 1005 | workflow_id (str): ID of the workflow to delete. 1006 | 1007 | Raises: 1008 | Exception: If the workflow deletion fails. 1009 | """ 1010 | url = f"{base_url}/api/v1/workflows/{workflow_id}" 1011 | headers = {"X-N8N-API-KEY": api_key} 1012 | response = requests.delete(url, headers=headers) 1013 | if response.status_code != 200: 1014 | raise Exception(f"Failed to delete workflow: {response.text}") 1015 | remove_resource_mapping(base_url, 'workflows', workflow_id) 1016 | 1017 | def delete_credential(api_key: str, base_url: str, credential_id: str) -> None: 1018 | """Delete a credential by ID. 1019 | 1020 | Args: 1021 | api_key (str): The API key for authentication. 1022 | base_url (str): The base URL of the n8n instance. 1023 | credential_id (str): ID of the credential to delete. 1024 | 1025 | Raises: 1026 | Exception: If the credential deletion fails. 1027 | """ 1028 | url = f"{base_url}/api/v1/credentials/{credential_id}" 1029 | headers = {"X-N8N-API-KEY": api_key} 1030 | response = requests.delete(url, headers=headers) 1031 | if response.status_code != 200: 1032 | raise Exception(f"Failed to delete credential: {response.text}") 1033 | remove_resource_mapping(base_url, 'credentials', credential_id) 1034 | 1035 | def delete_project(api_key: str, base_url: str, project_id: str) -> None: 1036 | """Delete a project by ID. 1037 | 1038 | Args: 1039 | api_key (str): The API key for authentication. 1040 | base_url (str): The base URL of the n8n instance. 1041 | project_id (str): ID of the project to delete. 1042 | 1043 | Raises: 1044 | Exception: If the project deletion fails. 1045 | """ 1046 | url = f"{base_url}/api/v1/projects/{project_id}" 1047 | headers = {"X-N8N-API-KEY": api_key} 1048 | response = requests.delete(url, headers=headers) 1049 | if response.status_code != 200: 1050 | raise Exception(f"Failed to delete project: {response.text}") 1051 | remove_resource_mapping(base_url, 'projects', project_id) 1052 | 1053 | def get_credentials(api_key: str, base_url: str) -> List[Dict]: 1054 | """Get all credentials from the instance. 1055 | 1056 | Args: 1057 | api_key (str): The API key for authentication. 1058 | base_url (str): The base URL of the n8n instance. 1059 | 1060 | Returns: 1061 | List[Dict]: List of credential dictionaries. 1062 | 1063 | Raises: 1064 | Exception: If the credentials retrieval fails. 1065 | """ 1066 | url = f"{base_url}/api/v1/credentials" 1067 | headers = {"X-N8N-API-KEY": api_key} 1068 | response = requests.get(url, headers=headers) 1069 | 1070 | if response.status_code != 200: 1071 | raise Exception(f"Failed to get credentials: {response.text}") 1072 | 1073 | return response.json()['data'] 1074 | 1075 | def save_resource_mapping(instance_url: str, resource_type: str, resource_id: str, resource_name: str) -> None: 1076 | """Save resource mapping to local storage. 1077 | 1078 | Args: 1079 | instance_url (str): URL of the n8n instance. 1080 | resource_type (str): Type of resource (workflows, credentials, projects). 1081 | resource_id (str): ID of the resource. 1082 | resource_name (str): Name of the resource. 1083 | """ 1084 | storage_file = 'resource_mapping.json' 1085 | try: 1086 | if os.path.exists(storage_file): 1087 | with open(storage_file, 'r') as f: 1088 | mappings = json.load(f) 1089 | else: 1090 | mappings = {} 1091 | 1092 | if instance_url not in mappings: 1093 | mappings[instance_url] = { 1094 | 'workflows': {}, 1095 | 'credentials': {}, 1096 | 'projects': {} 1097 | } 1098 | 1099 | mappings[instance_url][resource_type][resource_id] = resource_name 1100 | 1101 | with open(storage_file, 'w') as f: 1102 | json.dump(mappings, f, indent=2) 1103 | 1104 | except Exception as e: 1105 | print_error(f"Failed to save resource mapping: {str(e)}") 1106 | 1107 | def get_instance_resources(instance_url: str) -> Dict: 1108 | """Get all resources for an instance. 1109 | 1110 | Args: 1111 | instance_url (str): URL of the n8n instance. 1112 | 1113 | Returns: 1114 | Dict: Dictionary containing all resources for the instance. 1115 | """ 1116 | storage_file = 'resource_mapping.json' 1117 | try: 1118 | if os.path.exists(storage_file): 1119 | with open(storage_file, 'r') as f: 1120 | mappings = json.load(f) 1121 | return mappings.get(instance_url, { 1122 | 'workflows': {}, 1123 | 'credentials': {}, 1124 | 'projects': {} 1125 | }) 1126 | return { 1127 | 'workflows': {}, 1128 | 'credentials': {}, 1129 | 'projects': {} 1130 | } 1131 | except Exception as e: 1132 | print_error(f"Failed to load resource mappings: {str(e)}") 1133 | return { 1134 | 'workflows': {}, 1135 | 'credentials': {}, 1136 | 'projects': {} 1137 | } 1138 | 1139 | if __name__ == "__main__": 1140 | main() --------------------------------------------------------------------------------