feat: ship blog platform admin and deploy stack
This commit is contained in:
122
deploy/scripts/render_compose_env.py
Normal file
122
deploy/scripts/render_compose_env.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Missing dependency: PyYAML. Install it with `python -m pip install pyyaml`."
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render docker compose .env from deploy config.yaml"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
default="deploy/docker/config.yaml",
|
||||
help="Path to config.yaml (default: deploy/docker/config.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="deploy/docker/.env",
|
||||
help="Output dotenv file path (default: deploy/docker/.env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--section",
|
||||
default="compose_env",
|
||||
help="Top-level mapping section to export (default: compose_env)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stdout",
|
||||
action="store_true",
|
||||
help="Print rendered dotenv to stdout instead of writing file",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"Config file not found: {path}")
|
||||
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit("config.yaml root must be a mapping/object")
|
||||
return data
|
||||
|
||||
|
||||
def encode_env_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return '""'
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if not isinstance(value, str):
|
||||
raise SystemExit(f"compose_env only supports scalar values, got: {type(value).__name__}")
|
||||
|
||||
if value == "":
|
||||
return '""'
|
||||
|
||||
needs_quotes = any(ch in value for ch in [' ', '#', '"', "'", '\t', '\n', '\r']) or value.startswith('$')
|
||||
if not needs_quotes:
|
||||
return value
|
||||
|
||||
escaped = (
|
||||
value.replace('\\', '\\\\')
|
||||
.replace('"', '\\"')
|
||||
.replace('\n', '\\n')
|
||||
.replace('\r', '\\r')
|
||||
.replace('\t', '\\t')
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def render_env(section_name: str, values: dict[str, Any], source_path: Path) -> str:
|
||||
lines = [
|
||||
f"# Generated from {source_path.as_posix()}::{section_name}",
|
||||
"# Do not edit this file directly; edit config.yaml and re-render.",
|
||||
"",
|
||||
]
|
||||
|
||||
for key, value in values.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise SystemExit(f"Invalid env key: {key!r}")
|
||||
lines.append(f"{key}={encode_env_value(value)}")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
source_path = Path(args.input)
|
||||
output_path = Path(args.output)
|
||||
|
||||
data = load_config(source_path)
|
||||
section = data.get(args.section)
|
||||
if not isinstance(section, dict):
|
||||
raise SystemExit(
|
||||
f"Section `{args.section}` must exist in config.yaml and must be a mapping/object"
|
||||
)
|
||||
|
||||
rendered = render_env(args.section, section, source_path)
|
||||
|
||||
if args.stdout:
|
||||
sys.stdout.write(rendered)
|
||||
return 0
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(rendered, encoding="utf-8", newline="\n")
|
||||
print(f"Wrote {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user