- rebrand from 습관 트래커 to 해빗랩 across templates, manifest, service worker - add HTTPS redirect middleware for reverse-proxied deployments - add public landing page (/) and privacy policy page with real data handling disclosures - add in-app account deletion (Apple review requirement) - add Android TWA Digital Asset Links support (/.well-known/assetlinks.json) - add SFTP deployment script for the Synology-hosted server Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
4.2 KiB
Python
123 lines
4.2 KiB
Python
"""SFTP로 시놀로지 NAS에 소스를 동기화하는 배포 스크립트.
|
|
|
|
deploy.env(git 미포함, deploy.env.example 참고)의 접속 정보를 읽어, Dockerfile이 COPY하는
|
|
파일/디렉터리(pyproject.toml, alembic.ini, app/, migrations/, scripts/)와 Dockerfile,
|
|
docker-compose.yml을 원격 경로로 동기화한다. 로컬 mtime이 원격보다 최신인 파일만 올리고,
|
|
원격에만 있는 파일은 건드리지 않는다(단방향 추가/갱신, 삭제 없음). .env는 절대 동기화하지 않는다
|
|
(원격 프로덕션 .env를 덮어쓰면 안 되므로).
|
|
|
|
컨테이너 재시작/재빌드는 이 스크립트가 하지 않는다 — 파일을 올린 뒤 직접 재시작할 것.
|
|
|
|
사용법: python scripts/deploy_sftp.py [--dry-run]
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import paramiko
|
|
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
DEPLOY_ENV_PATH = PROJECT_ROOT / "deploy.env"
|
|
|
|
# Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일
|
|
SYNC_TARGETS = ["pyproject.toml", "alembic.ini", "Dockerfile", "docker-compose.yml", "app", "migrations", "scripts"]
|
|
SKIP_NAMES = {"__pycache__"}
|
|
SKIP_SUFFIXES = {".pyc"}
|
|
|
|
|
|
def load_deploy_env(path: Path) -> dict[str, str]:
|
|
if not path.exists():
|
|
print(f"{path}가 없습니다. deploy.env.example을 복사해 값을 채워주세요.")
|
|
sys.exit(1)
|
|
values = {}
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
values[key.strip()] = value.strip()
|
|
return values
|
|
|
|
|
|
def iter_local_files(target: Path):
|
|
if target.is_file():
|
|
yield target
|
|
return
|
|
for path in target.rglob("*"):
|
|
if path.is_dir():
|
|
continue
|
|
if any(part in SKIP_NAMES for part in path.parts):
|
|
continue
|
|
if path.suffix in SKIP_SUFFIXES:
|
|
continue
|
|
yield path
|
|
|
|
|
|
def ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None:
|
|
parts = remote_dir.strip("/").split("/")
|
|
current = ""
|
|
for part in parts:
|
|
current += "/" + part
|
|
try:
|
|
sftp.stat(current)
|
|
except FileNotFoundError:
|
|
sftp.mkdir(current)
|
|
|
|
|
|
def remote_mtime(sftp: paramiko.SFTPClient, remote_path: str) -> float | None:
|
|
try:
|
|
return sftp.stat(remote_path).st_mtime
|
|
except FileNotFoundError:
|
|
return None
|
|
|
|
|
|
def main() -> None:
|
|
dry_run = "--dry-run" in sys.argv
|
|
env = load_deploy_env(DEPLOY_ENV_PATH)
|
|
|
|
host = env["SFTP_HOST"]
|
|
port = int(env.get("SFTP_PORT", "22"))
|
|
username = env["SFTP_USERNAME"]
|
|
password = env["SFTP_PASSWORD"]
|
|
remote_root = env["SFTP_REMOTE_PATH"].rstrip("/")
|
|
|
|
print(f"{host}:{port} ({username}) -> {remote_root} 로 동기화{'(dry-run)' if dry_run else ''}")
|
|
|
|
transport = paramiko.Transport((host, port))
|
|
transport.connect(username=username, password=password)
|
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
assert sftp is not None
|
|
|
|
uploaded, skipped = 0, 0
|
|
try:
|
|
for target_name in SYNC_TARGETS:
|
|
local_target = PROJECT_ROOT / target_name
|
|
if not local_target.exists():
|
|
continue
|
|
for local_file in iter_local_files(local_target):
|
|
rel_path = local_file.relative_to(PROJECT_ROOT).as_posix()
|
|
remote_path = f"{remote_root}/{rel_path}"
|
|
local_mtime = local_file.stat().st_mtime
|
|
existing_mtime = remote_mtime(sftp, remote_path)
|
|
|
|
if existing_mtime is not None and existing_mtime >= local_mtime:
|
|
skipped += 1
|
|
continue
|
|
|
|
print(f" 업로드: {rel_path}")
|
|
if not dry_run:
|
|
ensure_remote_dir(sftp, str(Path(remote_path).parent.as_posix()))
|
|
sftp.put(str(local_file), remote_path)
|
|
uploaded += 1
|
|
finally:
|
|
sftp.close()
|
|
transport.close()
|
|
|
|
print(f"완료: {uploaded}개 업로드, {skipped}개 변경 없음. 컨테이너 재시작/재빌드는 직접 해주세요.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|