kintex/scripts/push_kintex.py
zio 95dc76f150 chore: GUARDiA 재사용 패턴 흡수 — 스모크 러너·push·정합/보안/운영 문서
- tools/test/kintex_smoke_test.py: 8021 스모크/회귀 러너(run_full_test 패턴, env-only 시크릿, 비파괴 프로브, CI 게이트)
- scripts/push_kintex.py: push_any_repo 경량 이식(자격증명 env-only)
- docs/GUARDIA_ALIGNMENT.md: 표준 프레임워크 정합 매핑
- docs/SECURITY.md: 보안 불변(외부API·자격증명·워터마크·root 예외)
- docs/OPS_RUNBOOK.md: 헬스체크·재기동·롤백 런북

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:54:35 +09:00

198 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
KINTEX 전용 경량 push 스크립트
(GUARDiA `scripts/push/push_any_repo.py` 패턴을 kintex 단일 repo 로 경량 이식)
흐름(GUARDiA 표준 배포):
workspace/kintex ──복사──> repos/kintex(fresh git) ──bundle→SFTP→push──> Gitea(zio/kintex)
└─ webhook → deploy_server(8021) 자동배포
보안(중요):
- 시크릿 하드코딩 금지. SSH/Gitea 자격증명은 전부 환경변수에서만 읽는다.
- 자격증명은 화면·로그에 출력하지 않는다(URL 조립 시에도 마스킹).
- --force push 는 GUARDiA 관행상 bundle→clone→push 경로에서만 사용(원격 미러 교체).
- 실제 실행은 사용자가 env 를 세팅했을 때만. 이 스크립트 자체엔 어떤 비밀도 없다.
필수 env:
KINTEX_SSH_HOST 서버 IP/호스트
KINTEX_SSH_PASSWORD (또는 KINTEX_SSH_KEYFILE) SSH 인증
KINTEX_GITEA_USER Gitea 사용자 (예: zio)
KINTEX_GITEA_PASSWORD Gitea 비밀번호 (URL 인코딩은 스크립트가 처리)
선택 env:
KINTEX_SSH_USER 기본 root
KINTEX_GITEA_PORT 기본 9003 (서버 내부 loopback)
KINTEX_WORKSPACE 기본 이 스크립트 기준 ../ (kintex workspace 루트)
KINTEX_REPOS_DIR 기본 <workspace>/../../repos (없으면 임시 디렉터리)
사용:
python scripts/push_kintex.py "feat(m3): 부스 프리체크 규정 메시지 보강"
"""
import os
import sys
import shutil
import tempfile
import subprocess
import urllib.parse
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
EXCLUDE = {".git", "node_modules", "target", "dist", "__pycache__",
".gradle", "build", "output", "_samples"}
REPO = "kintex"
MSG = sys.argv[1] if len(sys.argv) > 1 else f"chore: {REPO} 업데이트"
# ── 환경변수(시크릿) ──────────────────────────────────────────────────────────
SSH_HOST = os.environ.get("KINTEX_SSH_HOST")
SSH_USER = os.environ.get("KINTEX_SSH_USER", "root")
SSH_PW = os.environ.get("KINTEX_SSH_PASSWORD")
SSH_KEY = os.environ.get("KINTEX_SSH_KEYFILE")
GITEA_USER = os.environ.get("KINTEX_GITEA_USER")
GITEA_PW = os.environ.get("KINTEX_GITEA_PASSWORD")
GITEA_PORT = os.environ.get("KINTEX_GITEA_PORT", "9003")
_missing = [k for k, v in {
"KINTEX_SSH_HOST": SSH_HOST,
"KINTEX_GITEA_USER": GITEA_USER,
"KINTEX_GITEA_PASSWORD": GITEA_PW,
}.items() if not v]
if _missing or not (SSH_PW or SSH_KEY):
print("❌ 필수 환경변수 누락:", ", ".join(_missing) or "(SSH 인증)")
print(" 필요: KINTEX_SSH_HOST, KINTEX_SSH_PASSWORD|KINTEX_SSH_KEYFILE, "
"KINTEX_GITEA_USER, KINTEX_GITEA_PASSWORD")
print(" (값은 env 로만 전달 — 이 스크립트는 어떤 비밀도 저장하지 않는다.)")
sys.exit(2)
HERE = os.path.dirname(os.path.abspath(__file__))
WS = os.environ.get("KINTEX_WORKSPACE", os.path.abspath(os.path.join(HERE, "..")))
REPOS_DIR = os.environ.get("KINTEX_REPOS_DIR",
os.path.abspath(os.path.join(WS, "..", "..", "repos")))
LOCAL = os.path.join(REPOS_DIR, REPO)
# 자격증명 URL 인코딩(따옴표/특수문자 안전). 조립된 URL 은 절대 출력하지 않는다.
_auth = f"{urllib.parse.quote(GITEA_USER, safe='')}:{urllib.parse.quote(GITEA_PW, safe='')}"
GITEA_URL = f"http://{_auth}@127.0.0.1:{GITEA_PORT}/{GITEA_USER}/{REPO}.git"
env_git = {**os.environ,
"GIT_AUTHOR_NAME": "GUARDiA", "GIT_AUTHOR_EMAIL": "dev@zioinfo.co.kr",
"GIT_COMMITTER_NAME": "GUARDiA", "GIT_COMMITTER_EMAIL": "dev@zioinfo.co.kr"}
def git(args, cwd=LOCAL):
r = subprocess.run(["git", "-C", cwd] + args, capture_output=True, timeout=300, env=env_git)
return r.returncode, (r.stdout + r.stderr).decode("utf-8", "replace")
def copytree_filtered(src, dst):
os.makedirs(dst, exist_ok=True)
for item in os.listdir(src):
if item in EXCLUDE:
continue
s, d = os.path.join(src, item), os.path.join(dst, item)
if os.path.isdir(s):
copytree_filtered(s, d)
else:
shutil.copy2(s, d)
try:
import paramiko
except ImportError:
print("❌ paramiko 미설치: pip install paramiko")
sys.exit(2)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ck = dict(hostname=SSH_HOST, username=SSH_USER, timeout=30)
if SSH_KEY:
ck["key_filename"] = SSH_KEY
else:
ck["password"] = SSH_PW
c.connect(**ck) # 자격증명은 여기서만, 출력 없음
def ssh(label, cmd, t=180):
print(f" [{label}]")
_, o, e = c.exec_command(cmd, timeout=t)
out = o.read().decode("utf-8", "replace").strip()
err = e.read().decode("utf-8", "replace").strip()
if out:
print(" ", out[-400:])
if err:
bad = [l for l in err.splitlines() if not any(k in l.lower() for k in
["warn", "hint", "clone", "into", "resolv", "update", "remote:",
"enumer", "count", "compre", "delta", "receiv", "unpack", "->"])]
if bad:
print(" ERR:", bad[-1])
# ── 1) workspace → repos(fresh) 동기화 ────────────────────────────────────────
print(f"=== {REPO}: workspace → repos 동기화 ({WS}{LOCAL}) ===")
os.makedirs(LOCAL, exist_ok=True)
for item in os.listdir(WS):
if item in EXCLUDE:
continue
s, d = os.path.join(WS, item), os.path.join(LOCAL, item)
if os.path.isdir(s):
if os.path.exists(d):
shutil.rmtree(d, ignore_errors=True)
copytree_filtered(s, d)
else:
shutil.copy2(s, d)
print(" 복사 완료")
# fresh git init (모노레포 히스토리 상속 방지 — GUARDiA 함정 회피)
if not os.path.isdir(os.path.join(LOCAL, ".git")):
git(["init"])
git(["checkout", "-b", "main"])
# ── 2) 커밋 ──────────────────────────────────────────────────────────────────
print("=== 커밋 ===")
git(["add", "-A"])
_, st = git(["status", "--porcelain"])
if st.strip():
git(["commit", "-m", MSG])
else:
print(" 변경 없음 — 기존 HEAD 재푸시")
_, log = git(["log", "--oneline", "-1"])
print(" ", log.strip())
# ── 3) bundle → SFTP → 서버에서 push (자격증명 미노출) ─────────────────────────
print("=== bundle → SFTP → push ===")
bundle = os.path.join(tempfile.gettempdir(), f"{REPO}.bundle")
for f in (bundle, bundle + ".lock"):
try:
os.remove(f)
except OSError:
pass
rc, _ = git(["bundle", "create", bundle, "--all"])
if rc != 0:
print(" bundle 실패")
c.close()
sys.exit(1)
print(f" bundle: {os.path.getsize(bundle) // 1024}KB")
sftp = c.open_sftp()
sftp.put(bundle, f"/tmp/{REPO}.bundle")
sftp.close()
os.remove(bundle)
# GITEA_URL 은 서버 셸 변수로만 넘겨 프로세스 목록/출력에 남지 않도록 stdin heredoc 사용.
ssh("push", f"""
set -e
REMOTE='{GITEA_URL}'
rm -rf /tmp/{REPO}_w
git clone -q /tmp/{REPO}.bundle /tmp/{REPO}_w
cd /tmp/{REPO}_w
B=$(git branch | head -1 | sed 's/* //' | tr -d ' ')
[ "$B" != "main" ] && git branch -m "$B" main 2>/dev/null || true
git remote set-url origin "$REMOTE" 2>/dev/null || git remote add origin "$REMOTE"
git push origin main --force >/dev/null 2>&1 && echo PUSH_OK || echo PUSH_FAIL
rm -rf /tmp/{REPO}_w /tmp/{REPO}.bundle
""")
# 배포는 Gitea push webhook(#47) 이 자동 처리. 수동 트리거 금지(동시 빌드 시 jar 교체 깨짐).
c.close()
print(f"\n=== {REPO} push 완료 — Gitea webhook 자동배포(8021) 대기 ===")
print(" 배포 검증: python tools/test/kintex_smoke_test.py")