mirror of
https://github.com/langgenius/dify.git
synced 2026-03-23 23:37:55 +08:00
441 lines
14 KiB
Python
Executable File
441 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# ================================================================
|
|
# Dify Environment Variables Synchronization Script
|
|
#
|
|
# Features:
|
|
# - Synchronize latest settings from .env.example to .env
|
|
# - Preserve custom settings in existing .env
|
|
# - Add new environment variables
|
|
# - Detect removed environment variables
|
|
# - Create backup files
|
|
# ================================================================
|
|
|
|
import argparse
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
# ANSI color codes
|
|
RED = "\033[0;31m"
|
|
GREEN = "\033[0;32m"
|
|
YELLOW = "\033[1;33m"
|
|
BLUE = "\033[0;34m"
|
|
NC = "\033[0m" # No Color
|
|
|
|
|
|
def supports_color() -> bool:
|
|
"""Return True if the terminal supports ANSI color codes."""
|
|
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
|
|
|
|
def log_info(message: str) -> None:
|
|
"""Print an informational message in blue."""
|
|
if supports_color():
|
|
print(f"{BLUE}[INFO]{NC} {message}")
|
|
else:
|
|
print(f"[INFO] {message}")
|
|
|
|
|
|
def log_success(message: str) -> None:
|
|
"""Print a success message in green."""
|
|
if supports_color():
|
|
print(f"{GREEN}[SUCCESS]{NC} {message}")
|
|
else:
|
|
print(f"[SUCCESS] {message}")
|
|
|
|
|
|
def log_warning(message: str) -> None:
|
|
"""Print a warning message in yellow to stderr."""
|
|
if supports_color():
|
|
print(f"{YELLOW}[WARNING]{NC} {message}", file=sys.stderr)
|
|
else:
|
|
print(f"[WARNING] {message}", file=sys.stderr)
|
|
|
|
|
|
def log_error(message: str) -> None:
|
|
"""Print an error message in red to stderr."""
|
|
if supports_color():
|
|
print(f"{RED}[ERROR]{NC} {message}", file=sys.stderr)
|
|
else:
|
|
print(f"[ERROR] {message}", file=sys.stderr)
|
|
|
|
|
|
def parse_env_file(path: Path) -> dict[str, str]:
|
|
"""Parse an .env-style file and return a mapping of key to raw value.
|
|
|
|
Lines that are blank or start with '#' (after optional whitespace) are
|
|
skipped. Only lines containing '=' are considered variable definitions.
|
|
|
|
Args:
|
|
path: Path to the .env file to parse.
|
|
|
|
Returns:
|
|
Ordered dict mapping variable name to its value string.
|
|
"""
|
|
variables: dict[str, str] = {}
|
|
with path.open(encoding="utf-8") as fh:
|
|
for line in fh:
|
|
line = line.rstrip("\n")
|
|
# Skip blank lines and comment lines
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
key = key.strip()
|
|
if key:
|
|
variables[key] = value.strip()
|
|
return variables
|
|
|
|
|
|
def check_files(work_dir: Path) -> None:
|
|
"""Verify required files exist; create .env from .env.example if absent.
|
|
|
|
Args:
|
|
work_dir: Directory that must contain .env.example (and optionally .env).
|
|
|
|
Raises:
|
|
SystemExit: If .env.example does not exist.
|
|
"""
|
|
log_info("Checking required files...")
|
|
|
|
example_file = work_dir / ".env.example"
|
|
env_file = work_dir / ".env"
|
|
|
|
if not example_file.exists():
|
|
log_error(".env.example file not found")
|
|
sys.exit(1)
|
|
|
|
if not env_file.exists():
|
|
log_warning(".env file does not exist. Creating from .env.example.")
|
|
shutil.copy2(example_file, env_file)
|
|
log_success(".env file created")
|
|
|
|
log_success("Required files verified")
|
|
|
|
|
|
def create_backup(work_dir: Path) -> None:
|
|
"""Create a timestamped backup of the current .env file.
|
|
|
|
Backups are placed in ``<work_dir>/env-backup/`` with the filename
|
|
``.env.backup_<YYYYMMDD_HHMMSS>``.
|
|
|
|
Args:
|
|
work_dir: Directory containing the .env file to back up.
|
|
"""
|
|
env_file = work_dir / ".env"
|
|
if not env_file.exists():
|
|
return
|
|
|
|
backup_dir = work_dir / "env-backup"
|
|
if not backup_dir.exists():
|
|
backup_dir.mkdir(parents=True)
|
|
log_info(f"Created backup directory: {backup_dir}")
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_file = backup_dir / f".env.backup_{timestamp}"
|
|
shutil.copy2(env_file, backup_file)
|
|
log_success(f"Backed up existing .env to {backup_file}")
|
|
|
|
|
|
def analyze_value_change(current: str, recommended: str) -> str | None:
|
|
"""Analyse what kind of change occurred between two env values.
|
|
|
|
Args:
|
|
current: Value currently set in .env.
|
|
recommended: Value present in .env.example.
|
|
|
|
Returns:
|
|
A human-readable description string, or None when no analysis applies.
|
|
"""
|
|
use_colors = supports_color()
|
|
|
|
def colorize(color: str, text: str) -> str:
|
|
return f"{color}{text}{NC}" if use_colors else text
|
|
|
|
if not current and recommended:
|
|
return colorize(RED, " -> Setting from empty to recommended value")
|
|
if current and not recommended:
|
|
return colorize(RED, " -> Recommended value changed to empty")
|
|
|
|
# Numeric comparison
|
|
if re.fullmatch(r"\d+", current) and re.fullmatch(r"\d+", recommended):
|
|
cur_int, rec_int = int(current), int(recommended)
|
|
if cur_int < rec_int:
|
|
return colorize(BLUE, f" -> Numeric increase ({current} < {recommended})")
|
|
if cur_int > rec_int:
|
|
return colorize(YELLOW, f" -> Numeric decrease ({current} > {recommended})")
|
|
return None
|
|
|
|
# Boolean comparison
|
|
if current.lower() in {"true", "false"} and recommended.lower() in {"true", "false"}:
|
|
if current.lower() != recommended.lower():
|
|
return colorize(BLUE, f" -> Boolean value change ({current} -> {recommended})")
|
|
return None
|
|
|
|
# URL / endpoint
|
|
if current.startswith(("http://", "https://")) or recommended.startswith(("http://", "https://")):
|
|
return colorize(BLUE, " -> URL/endpoint change")
|
|
|
|
# File path
|
|
if current.startswith("/") or recommended.startswith("/"):
|
|
return colorize(BLUE, " -> File path change")
|
|
|
|
# String length
|
|
if len(current) != len(recommended):
|
|
return colorize(YELLOW, f" -> String length change ({len(current)} -> {len(recommended)} characters)")
|
|
|
|
return None
|
|
|
|
|
|
def detect_differences(env_vars: dict[str, str], example_vars: dict[str, str]) -> dict[str, tuple[str, str]]:
|
|
"""Find variables whose values differ between .env and .env.example.
|
|
|
|
Only variables present in *both* files are compared; new or removed
|
|
variables are handled by separate functions.
|
|
|
|
Args:
|
|
env_vars: Parsed key/value pairs from .env.
|
|
example_vars: Parsed key/value pairs from .env.example.
|
|
|
|
Returns:
|
|
Mapping of key -> (env_value, example_value) for every key whose
|
|
values differ.
|
|
"""
|
|
log_info("Detecting differences between .env and .env.example...")
|
|
|
|
diffs: dict[str, tuple[str, str]] = {}
|
|
for key, example_value in example_vars.items():
|
|
if key in env_vars and env_vars[key] != example_value:
|
|
diffs[key] = (env_vars[key], example_value)
|
|
|
|
if diffs:
|
|
log_success(f"Detected differences in {len(diffs)} environment variables")
|
|
show_differences_detail(diffs)
|
|
else:
|
|
log_info("No differences detected")
|
|
|
|
return diffs
|
|
|
|
|
|
def show_differences_detail(diffs: dict[str, tuple[str, str]]) -> None:
|
|
"""Print a formatted table of differing environment variables.
|
|
|
|
Args:
|
|
diffs: Mapping of key -> (current_value, recommended_value).
|
|
"""
|
|
use_colors = supports_color()
|
|
|
|
log_info("")
|
|
log_info("=== Environment Variable Differences ===")
|
|
|
|
if not diffs:
|
|
log_info("No differences to display")
|
|
return
|
|
|
|
for count, (key, (env_value, example_value)) in enumerate(diffs.items(), start=1):
|
|
print()
|
|
if use_colors:
|
|
print(f"{YELLOW}[{count}] {key}{NC}")
|
|
print(f" {GREEN}.env (current){NC} : {env_value}")
|
|
print(f" {BLUE}.env.example (recommended){NC} : {example_value}")
|
|
else:
|
|
print(f"[{count}] {key}")
|
|
print(f" .env (current) : {env_value}")
|
|
print(f" .env.example (recommended) : {example_value}")
|
|
|
|
analysis = analyze_value_change(env_value, example_value)
|
|
if analysis:
|
|
print(analysis)
|
|
|
|
print()
|
|
log_info("=== Difference Analysis Complete ===")
|
|
log_info("Note: Consider changing to the recommended values above.")
|
|
log_info("Current implementation preserves .env values.")
|
|
print()
|
|
|
|
|
|
def detect_removed_variables(env_vars: dict[str, str], example_vars: dict[str, str]) -> list[str]:
|
|
"""Identify variables present in .env but absent from .env.example.
|
|
|
|
Args:
|
|
env_vars: Parsed key/value pairs from .env.
|
|
example_vars: Parsed key/value pairs from .env.example.
|
|
|
|
Returns:
|
|
Sorted list of variable names that no longer appear in .env.example.
|
|
"""
|
|
log_info("Detecting removed environment variables...")
|
|
|
|
removed = sorted(set(env_vars) - set(example_vars))
|
|
|
|
if removed:
|
|
log_warning("The following environment variables have been removed from .env.example:")
|
|
for var in removed:
|
|
log_warning(f" - {var}")
|
|
log_warning("Consider manually removing these variables from .env")
|
|
else:
|
|
log_success("No removed environment variables found")
|
|
|
|
return removed
|
|
|
|
|
|
def sync_env_file(work_dir: Path, env_vars: dict[str, str], diffs: dict[str, tuple[str, str]]) -> None:
|
|
"""Rewrite .env based on .env.example while preserving custom values.
|
|
|
|
The output file follows the exact line structure of .env.example
|
|
(preserving comments, blank lines, and ordering). For every variable
|
|
that exists in .env with a different value from the example, the
|
|
current .env value is kept. Variables that are new in .env.example
|
|
(not present in .env at all) are added with the example's default.
|
|
|
|
Args:
|
|
work_dir: Directory containing .env and .env.example.
|
|
env_vars: Parsed key/value pairs from the original .env.
|
|
diffs: Keys whose .env values differ from .env.example (to preserve).
|
|
"""
|
|
log_info("Starting partial synchronization of .env file...")
|
|
|
|
example_file = work_dir / ".env.example"
|
|
new_env_file = work_dir / ".env.new"
|
|
|
|
# Keys whose current .env value should override the example default
|
|
preserved_keys: set[str] = set(diffs.keys())
|
|
|
|
preserved_count = 0
|
|
updated_count = 0
|
|
|
|
env_var_pattern = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
|
|
|
with example_file.open(encoding="utf-8") as src, new_env_file.open("w", encoding="utf-8") as dst:
|
|
for line in src:
|
|
raw_line = line.rstrip("\n")
|
|
match = env_var_pattern.match(raw_line)
|
|
if match:
|
|
key = match.group(1)
|
|
if key in preserved_keys:
|
|
# Write the preserved value from .env
|
|
dst.write(f"{key}={env_vars[key]}\n")
|
|
log_info(f" Preserved: {key} (.env value)")
|
|
preserved_count += 1
|
|
else:
|
|
# Use the example value (covers new vars and unchanged ones)
|
|
dst.write(line if line.endswith("\n") else raw_line + "\n")
|
|
updated_count += 1
|
|
else:
|
|
# Blank line, comment, or non-variable line — keep as-is
|
|
dst.write(line if line.endswith("\n") else raw_line + "\n")
|
|
|
|
# Atomically replace the original .env
|
|
try:
|
|
new_env_file.replace(work_dir / ".env")
|
|
except OSError as exc:
|
|
log_error(f"Failed to replace .env file: {exc}")
|
|
new_env_file.unlink(missing_ok=True)
|
|
sys.exit(1)
|
|
|
|
log_success("Successfully created new .env file")
|
|
log_success("Partial synchronization of .env file completed")
|
|
log_info(f" Preserved .env values: {preserved_count}")
|
|
log_info(f" Updated to .env.example values: {updated_count}")
|
|
|
|
|
|
def show_statistics(work_dir: Path) -> None:
|
|
"""Print a summary of variable counts from both env files.
|
|
|
|
Args:
|
|
work_dir: Directory containing .env and .env.example.
|
|
"""
|
|
log_info("Synchronization statistics:")
|
|
|
|
example_file = work_dir / ".env.example"
|
|
env_file = work_dir / ".env"
|
|
|
|
example_count = len(parse_env_file(example_file)) if example_file.exists() else 0
|
|
env_count = len(parse_env_file(env_file)) if env_file.exists() else 0
|
|
|
|
log_info(f" .env.example environment variables: {example_count}")
|
|
log_info(f" .env environment variables: {env_count}")
|
|
|
|
|
|
def build_arg_parser() -> argparse.ArgumentParser:
|
|
"""Build and return the CLI argument parser.
|
|
|
|
Returns:
|
|
Configured ArgumentParser instance.
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
prog="dify-env-sync",
|
|
description=(
|
|
"Synchronize .env with .env.example: add new variables, "
|
|
"preserve custom values, and report removed variables."
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=(
|
|
"Examples:\n"
|
|
" # Run from the docker/ directory (default)\n"
|
|
" python dify-env-sync.py\n\n"
|
|
" # Specify a custom working directory\n"
|
|
" python dify-env-sync.py --dir /path/to/docker\n"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--dir",
|
|
metavar="DIRECTORY",
|
|
default=".",
|
|
help="Working directory containing .env and .env.example (default: current directory)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-backup",
|
|
action="store_true",
|
|
default=False,
|
|
help="Skip creating a timestamped backup of the existing .env file",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
"""Orchestrate the complete environment variable synchronization process."""
|
|
parser = build_arg_parser()
|
|
args = parser.parse_args()
|
|
|
|
work_dir = Path(args.dir).resolve()
|
|
|
|
log_info("=== Dify Environment Variables Synchronization Script ===")
|
|
log_info(f"Execution started: {datetime.now()}")
|
|
log_info(f"Working directory: {work_dir}")
|
|
|
|
# 1. Verify prerequisites
|
|
check_files(work_dir)
|
|
|
|
# 2. Backup existing .env
|
|
if not args.no_backup:
|
|
create_backup(work_dir)
|
|
|
|
# 3. Parse both files
|
|
env_vars = parse_env_file(work_dir / ".env")
|
|
example_vars = parse_env_file(work_dir / ".env.example")
|
|
|
|
# 4. Report differences (values that changed in the example)
|
|
diffs = detect_differences(env_vars, example_vars)
|
|
|
|
# 5. Report variables removed from the example
|
|
detect_removed_variables(env_vars, example_vars)
|
|
|
|
# 6. Rewrite .env
|
|
sync_env_file(work_dir, env_vars, diffs)
|
|
|
|
# 7. Print summary statistics
|
|
show_statistics(work_dir)
|
|
|
|
log_success("=== Synchronization process completed successfully ===")
|
|
log_info(f"Execution finished: {datetime.now()}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|