#!/usr/bin/env bash ############################################################################### # patches/ 에 둔 파일을 스택에 적용한다. # # ./bin/apply-patches.sh # patches/ 를 훑어 compose.patches.yml 을 만든다 # ./bin/apply-patches.sh --list # 무엇이 적용될지만 보여준다 # ./bin/apply-patches.sh --clear # 적용을 해제한다 (compose.patches.yml 삭제) # # 폐쇄망에서는 이미지를 다시 빌드할 수 없다. 대신 바꾼 파일을 컨테이너의 같은 경로 위에 # 파일 단위로 bind mount 한다. 나머지 파일은 이미지 것을 그대로 쓴다. # # patches/app/<경로> → 앱 이미지를 쓰는 모든 서비스의 /app/<경로> # (bootstrap·web·worker-*·scheduler) # patches/processing/<경로> → alpha-processing 의 /www/alpha-processing/<경로> # # 만들어진 compose.patches.yml 은 up-compose.sh 와 up-*-run.sh 가 자동으로 읽는다. # 컨테이너를 다시 만들어도 유지된다. # # 권한: 컨테이너는 app(앱·처리 이미지 모두 uid 10001)으로 돌고 bind mount 는 # 호스트 uid/gid 를 그대로 넘긴다. 읽기 전용 마운트라 쓰기 권한은 필요 없지만, 파일이 # 다른 사용자에게 읽히지 않으면(예: umask 077 호스트의 0600) 컨테이너는 뜬 뒤 그 파일을 # 열 때 Permission denied 로 실패한다. 그래서 이 스크립트가 파일 모드를 a+r 로 맞추고, # 실제 이미지로 읽어 보는 것까지 확인한다. 파일 단위 마운트라 디렉터리 권한은 무관하다. # # 원본을 꺼내는 방법: # mkdir -p patches/app/prompts # docker run --rm --entrypoint sh pi-continuum:76d67d4 \ # -c 'cat /app/prompts/interview_system.md' > patches/app/prompts/interview_system.md ############################################################################### . "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" PATCH_DIR="${DEPLOY_DIR}/patches" OUT="${DEPLOY_DIR}/compose.patches.yml" # 앱 이미지를 쓰는 서비스. compose 에서 읽어 목록을 하드코딩하지 않는다. APP_SERVICES=() PROC_SERVICE="" MODE=apply for arg in "$@"; do case "${arg}" in --list) MODE=list ;; --clear) MODE=clear ;; -h|--help) sed -n '2,26p' "$0"; exit 0 ;; *) die "알 수 없는 인자: ${arg}" ;; esac done if [ "${MODE}" = clear ]; then if [ -f "${OUT}" ]; then rm -f "${OUT}" log "적용 해제: compose.patches.yml 삭제. 다시 띄우면 이미지 원본으로 돌아간다." else log "적용된 패치가 없다." fi exit 0 fi [ -d "${PATCH_DIR}" ] || die "patches/ 디렉터리가 없다." [ -f "${ENV_FILE}" ] && load_env # 어떤 서비스가 어느 이미지를 쓰는지 compose 에서 읽는다. resolve_engine "${ENGINE_ARG:-docker}" 2>/dev/null || true if command -v python3 >/dev/null 2>&1; then while IFS=$'\t' read -r svc kind; do [ -n "${svc}" ] || continue [ "${kind}" = app ] && APP_SERVICES+=("${svc}") [ "${kind}" = proc ] && PROC_SERVICE="${svc}" done < <(python3 - "${DEPLOY_DIR}/compose.yml" <<'PY' import os, sys sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(sys.argv[1])), "bin")) try: import yaml load = yaml.safe_load except ImportError: import _miniyaml load = _miniyaml.safe_load doc = load(open(sys.argv[1], encoding="utf-8").read()) app = os.environ.get("APP_IMAGE", "") or "pi-continuum" proc = os.environ.get("PROCESSING_IMAGE", "") or "alpha-processing" for name, svc in (doc.get("services") or {}).items(): image = str((svc or {}).get("image") or "") # ${APP_IMAGE:-...} 형태 그대로거나, .env 로 치환한 값과 같으면 앱 서비스로 본다. if "APP_IMAGE" in image or image.split(":")[0].endswith("pi-continuum") or image == app: print("%s\tapp" % name) elif "PROCESSING_IMAGE" in image or "alpha-processing" in image or image == proc: print("%s\tproc" % name) PY ) fi [ "${#APP_SERVICES[@]}" -gt 0 ] || die "compose 에서 앱 서비스를 찾지 못했다." # ── patches/ 훑기 ─────────────────────────────────────────────────────────── declare -a APP_FILES=() PROC_FILES=() if [ -d "${PATCH_DIR}/app" ]; then while IFS= read -r f; do APP_FILES+=("${f#"${PATCH_DIR}/app/"}"); done \ < <(find "${PATCH_DIR}/app" -type f ! -name '.gitkeep' | sort) fi if [ -d "${PATCH_DIR}/processing" ]; then while IFS= read -r f; do PROC_FILES+=("${f#"${PATCH_DIR}/processing/"}"); done \ < <(find "${PATCH_DIR}/processing" -type f ! -name '.gitkeep' | sort) fi total=$(( ${#APP_FILES[@]} + ${#PROC_FILES[@]} )) if [ "${total}" -eq 0 ]; then log "patches/ 에 적용할 파일이 없다." log " patches/app/<경로> → 컨테이너의 /app/<경로>" log " patches/processing/<경로> → 컨테이너의 /www/alpha-processing/<경로>" if [ "${MODE}" = apply ] && [ -f "${OUT}" ]; then rm -f "${OUT}" log "빈 상태이므로 compose.patches.yml 을 지웠다." fi exit 0 fi log "적용 대상 ${total} 개" for rel in ${APP_FILES[@]+"${APP_FILES[@]}"}; do printf ' app patches/app/%-45s → /app/%s\n' "${rel}" "${rel}" done for rel in ${PROC_FILES[@]+"${PROC_FILES[@]}"}; do printf ' processing patches/processing/%-38s → /www/alpha-processing/%s\n' "${rel}" "${rel}" done proc_note="" [ -n "${PROC_SERVICE}" ] && [ "${#PROC_FILES[@]}" -gt 0 ] && proc_note=" ${PROC_SERVICE}" [ "${#APP_FILES[@]}" -gt 0 ] \ && printf ' 적용 서비스: %s%s\n' "${APP_SERVICES[*]}" "${proc_note}" \ || printf ' 적용 서비스:%s\n' "${proc_note}" if [ "${MODE}" = list ]; then exit 0 fi # ── 권한 보정 ─────────────────────────────────────────────────────────────── # 컨테이너 사용자가 읽을 수 있어야 한다. 읽기 전용 마운트이므로 a+r 이면 충분하다. fixed=0 make_readable() { local f="$1" before after before="$(stat -c '%a' "${f}" 2>/dev/null || echo '?')" chmod a+r "${f}" 2>/dev/null || warn "모드를 바꾸지 못했다 (소유자가 다른가?): ${f}" after="$(stat -c '%a' "${f}" 2>/dev/null || echo '?')" if [ "${before}" != "${after}" ]; then printf ' %s %s → %s\n' "${f#"${PATCH_DIR}/"}" "${before}" "${after}" fixed=$((fixed + 1)) fi } for rel in ${APP_FILES[@]+"${APP_FILES[@]}"}; do make_readable "${PATCH_DIR}/app/${rel}"; done for rel in ${PROC_FILES[@]+"${PROC_FILES[@]}"}; do make_readable "${PATCH_DIR}/processing/${rel}"; done [ "${fixed}" -gt 0 ] && log "파일 모드를 a+r 로 보정했다 (${fixed} 개) — 컨테이너 사용자가 읽어야 한다" # ── 실제 이미지로 읽어 보기 ───────────────────────────────────────────────── # 모드만 보고 넘기지 않는다. 컨테이너 사용자로 정말 열리는지 확인한다. verify_readable() { # verify_readable <이미지> <컨테이너 루트> <파일...> local image="$1" root="$2" sub="$3"; shift 3 [ "$#" -gt 0 ] || return 0 engine image inspect "${image}" >/dev/null 2>&1 || { warn "${image} 가 없어 읽기 확인을 건너뛴다 (적재 후 다시 실행하면 확인한다)." return 0 } local mounts=() rel targets="" for rel in "$@"; do mounts+=(-v "${PATCH_DIR}/${sub}/${rel}:${root}/${rel}:ro") targets="${targets} ${root}/${rel}" done if engine run --rm "${mounts[@]}" --entrypoint sh "${image}" \ -c "for f in ${targets}; do head -c 1 \"\$f\" >/dev/null || exit 1; done" >/dev/null 2>&1; then log "읽기 확인 통과: ${image} (uid $(engine run --rm --entrypoint sh "${image}" -c 'id -u'))" else die "컨테이너 사용자가 패치 파일을 읽지 못한다 (${image}). 호스트 파일 모드를 확인한다 — 읽기 전용 마운트라 a+r 이면 충분하다: chmod a+r ${PATCH_DIR}/${sub}/*" fi } verify_readable "${APP_IMAGE:-pi-continuum:76d67d4}" /app app ${APP_FILES[@]+"${APP_FILES[@]}"} verify_readable "${PROCESSING_IMAGE:-alpha-processing:b11f26b}" /www/alpha-processing processing ${PROC_FILES[@]+"${PROC_FILES[@]}"} # ── compose.patches.yml 생성 ──────────────────────────────────────────────── # SELinux enforcing 호스트에서는 bind mount 에 라벨이 필요하다 (SELINUX_LABEL=Z). MOUNT_SUFFIX="ro" case "${SELINUX_LABEL:-}" in z|Z) MOUNT_SUFFIX="ro,${SELINUX_LABEL}" ;; esac # compose 의 volumes 는 병합이 아니라 덮어쓰기이므로, 서비스별 override 로 얹는다. # (docker compose 는 -f 를 여러 번 주면 리스트를 이어 붙인다.) { printf '# 자동 생성 파일 — bin/apply-patches.sh 가 만든다. 직접 고치지 않는다.\n' printf '# patches/ 의 파일을 컨테이너의 같은 경로 위에 파일 단위로 덮어쓴다.\n' printf '# 해제: ./bin/apply-patches.sh --clear\n' printf '\nservices:\n' for svc in "${APP_SERVICES[@]}"; do printf ' %s:\n volumes:\n' "${svc}" for rel in ${APP_FILES[@]+"${APP_FILES[@]}"}; do printf ' - ./patches/app/%s:/app/%s:%s\n' "${rel}" "${rel}" "${MOUNT_SUFFIX}" done done if [ -n "${PROC_SERVICE}" ] && [ "${#PROC_FILES[@]}" -gt 0 ]; then printf ' %s:\n volumes:\n' "${PROC_SERVICE}" for rel in "${PROC_FILES[@]}"; do printf ' - ./patches/processing/%s:/www/alpha-processing/%s:%s\n' "${rel}" "${rel}" "${MOUNT_SUFFIX}" done fi } > "${OUT}" log "작성: compose.patches.yml" cat <