#!/usr/bin/env bash # ============================================================================ # apply-product-rate-limit-policies.sh # # CSV(subscription_id,product_id,rate_limit)를 읽어 각 제품(Product)의 정책에 # rate-limit 정책조각(Policy Fragment)을 로 연결한다. # # - subscription_id : APIM 구독(Subscription) ID. URL 구성에는 사용하지 않고 # 제품 매핑 확인/로그 용도로만 사용한다. # - product_id : 정책을 적용할 APIM Product의 리소스 이름(이미 생성돼 있어야 함) # - rate_limit : 초당 허용 호출 수. policies/fragments/rate-limit--per-sec.xml # 정책조각(이미 생성돼 있어야 함)에 매핑된다. # # 정책조각(Policy Fragment) 자체는 이 스크립트가 만들지 않는다. 이미 APIM에 # 생성돼 있다고 가정하고, fragments 폴더의 xml 파일명은 "그 정책조각 리소스 # 이름을 확인하기 위한 로컬 참고용"으로만 쓴다. 실제 적용 전에 az rest GET으로 # 제품/정책조각이 APIM에 실존하는지 다시 확인한다(로컬 파일만 믿지 않음). # # 사전조건: # - az CLI 로그인이 이미 되어 있는 상태에서 실행 (이 스크립트는 az login을 하지 않음) # - 제품(Product), 정책조각(Policy Fragment)이 대상 APIM 인스턴스에 이미 생성돼 있음 # # 사용법: # ./apply-product-rate-limit-policies.sh [--dry-run] [--skip-remote-check] \ # # # --dry-run : 실제 PUT을 호출하지 않고 무엇을 적용할지만 출력 # --skip-remote-check : 제품/정책조각 존재 여부 원격(az rest GET) 확인을 생략 # (로컬 fragment 파일 존재 확인은 계속 수행됨). 기본은 확인함. # resourceGroupName : APIM이 속한 리소스 그룹 이름 (필수) # APIM인스턴스명 : 대상 API Management 서비스 이름 (필수) # CSV파일명 : 이 스크립트와 같은 디렉토리에 있는 CSV 파일명 (필수) # # CSV 형식 (헤더 포함, 정확히 아래 순서/이름이어야 함): # subscription_id,product_id,rate_limit # svc-0000-audi-gis-info,product-0000-audi-gis-info,34 # # 재실행 시: # 이미 적용된 행을 다시 실행해도 PUT은 멱등(idempotent)이라 안전하다. # 실패한 행은 failed_rows_<타임스탬프>.csv 로 저장되므로, 그 파일만 다시 # 이 스크립트의 CSV 인자로 넘겨 재실행하면 된다. # ============================================================================ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PRODUCT_API_VERSION="2022-08-01" # products / products policy FRAGMENT_API_VERSION="2023-05-01-preview" # policyFragments (modules/apim-fragments.bicep과 동일 버전) FRAGMENTS_DIR="${SCRIPT_DIR}/policies/fragments" DELAY_SECONDS="${DELAY_SECONDS:-0.2}" # 호출 간 간격(초). 환경변수로 조절 가능. BODY_FILE="" cleanup() { [ -n "$BODY_FILE" ] && rm -f "$BODY_FILE"; } trap cleanup EXIT DRY_RUN=0 SKIP_REMOTE_CHECK=0 usage() { cat >&2 < EOF exit 1 } # ---- 옵션 파싱 ------------------------------------------------------------- while [ "${1:-}" ]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --skip-remote-check) SKIP_REMOTE_CHECK=1; shift ;; --) shift; break ;; -*) echo "알 수 없는 옵션: $1" >&2; usage ;; *) break ;; esac done [ $# -eq 3 ] || usage RESOURCE_GROUP="$1" APIM_SERVICE="$2" CSV_FILE_NAME="$3" CSV_FILE="${SCRIPT_DIR}/${CSV_FILE_NAME}" TIMESTAMP="$(date +%Y%m%d_%H%M%S)" LOG_FILE="${SCRIPT_DIR}/apply-product-rate-limit-policies_${TIMESTAMP}.log" FAILED_CSV="${SCRIPT_DIR}/failed_rows_${TIMESTAMP}.csv" # ---- 로그: 화면 출력과 동시에 파일에도 남긴다 ------------------------------- exec > >(tee -a "$LOG_FILE") 2>&1 log() { echo "[$(date +%H:%M:%S)] $*"; } fail_row() { # fail_row <사유> echo "$1,$2,$3" >> "$FAILED_CSV" log " -> 실패: product_id=$2 rate_limit=$3 사유: $4" } # ---- 사전 점검 -------------------------------------------------------------- command -v az >/dev/null 2>&1 || { echo "az CLI가 설치되어 있지 않습니다." >&2; exit 1; } if [ ! -f "$CSV_FILE" ]; then echo "CSV 파일을 찾을 수 없습니다: ${CSV_FILE}" >&2 exit 1 fi if [ ! -d "$FRAGMENTS_DIR" ]; then echo "정책조각 폴더를 찾을 수 없습니다: ${FRAGMENTS_DIR}" >&2 exit 1 fi log "구독 ID 조회 중..." if ! SUBSCRIPTION_ID=$(az account show --query id -o tsv --only-show-errors); then echo "구독 ID 조회 실패. 'az login' 상태를 확인하세요." >&2 exit 1 fi log "구독 ID: ${SUBSCRIPTION_ID}" log "리소스 그룹: ${RESOURCE_GROUP}" log "APIM 인스턴스: ${APIM_SERVICE}" log "CSV 파일: ${CSV_FILE}" log "정책조각 폴더: ${FRAGMENTS_DIR}" log "dry-run: ${DRY_RUN}, skip-remote-check: ${SKIP_REMOTE_CHECK}" log "---" APIM_BASE="https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.ApiManagement/service/${APIM_SERVICE}" # 대상 APIM 인스턴스 자체가 존재하는지 먼저 확인 (200행 전부 돌기 전에 조기 실패) log "APIM 인스턴스 존재 확인 중..." if ! az rest --method GET --uri "${APIM_BASE}?api-version=${PRODUCT_API_VERSION}" --only-show-errors >/dev/null 2>&1; then echo "오류: APIM 인스턴스를 찾을 수 없습니다 (resourceGroup=${RESOURCE_GROUP}, name=${APIM_SERVICE})." >&2 exit 1 fi log "APIM 인스턴스 확인됨." log "---" # ---- 헤더 검증 --------------------------------------------------------------- RAW_HEADER="$(head -n 1 "$CSV_FILE" | tr -d '\r')" # UTF-8 BOM 제거 HEADER="${RAW_HEADER#$'\xEF\xBB\xBF'}" EXPECTED_HEADER="subscription_id,product_id,rate_limit" if [ "$HEADER" != "$EXPECTED_HEADER" ]; then echo "오류: CSV 헤더가 예상과 다릅니다." >&2 echo " 예상: ${EXPECTED_HEADER}" >&2 echo " 실제: ${HEADER}" >&2 exit 1 fi # ---- JSON 문자열 이스케이프 ------------------------------------------------- # 주의: 백슬래시/큰따옴표 치환은 bash의 ${s//pat/rep} 파라미터 치환만으로는 # 백슬래시를 안정적으로 두 배로 만들 수 없어(환경에 따라 백슬래시가 그냥 # 사라지는 경우가 확인됨) sed로 처리한다. 탭/개행 치환은 파라미터 치환으로 # 정상 동작함을 확인했다(od 기반 바이트 단위 테스트로 검증됨). json_escape() { local s=$1 s=$(printf '%s' "$s" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g') s=${s//$'\t'/"\\t"} s=${s//$'\r'/} s=${s//$'\n'/"\\n"} printf '%s' "$s" } # ---- az rest GET로 리소스 존재 여부 확인 ------------------------------------ # 반환값: 0=존재, 1=404(없음), 2=그 외 오류(LAST_ERROR에 상세 저장) LAST_ERROR="" remote_exists() { local url="$1" local out if out=$(az rest --method GET --uri "$url" --only-show-errors 2>&1); then return 0 fi if echo "$out" | grep -qiE 'ResourceNotFound|"code"[[:space:]]*:[[:space:]]*"?NotFound|HTTP/1\.1 404|\(404\)|status code 404'; then return 1 fi LAST_ERROR="$out" return 2 } TOTAL=0 SUCCESS=0 SKIPPED=0 echo "subscription_id,product_id,rate_limit" > "$FAILED_CSV" # process substitution을 사용해 while 루프를 서브셸이 아닌 현재 셸에서 실행 # (파이프로 연결하면 서브셸에서 돌아 카운터 변수가 루프 밖으로 반영되지 않음) while IFS=',' read -r subscription_id product_id rate_limit || [ -n "${subscription_id:-}" ]; do # 빈 줄 스킵 [ -z "${subscription_id// }" ] && [ -z "${product_id// }" ] && [ -z "${rate_limit// }" ] && continue subscription_id=$(echo "${subscription_id:-}" | xargs) product_id=$(echo "${product_id:-}" | xargs) rate_limit=$(echo "${rate_limit:-}" | xargs) TOTAL=$((TOTAL + 1)) log "[${TOTAL}] 처리 중: subscription_id=${subscription_id} product_id=${product_id} rate_limit=${rate_limit}" # 방어 1: 컬럼 개수/필수값 검증 if [ -z "$subscription_id" ] || [ -z "$product_id" ] || [ -z "$rate_limit" ]; then SKIPPED=$((SKIPPED + 1)) fail_row "${subscription_id:-}" "${product_id:-}" "${rate_limit:-}" "필수 컬럼 누락(빈 값)" continue fi # 방어 2: rate_limit이 양의 정수인지 검증 if ! [[ "$rate_limit" =~ ^[1-9][0-9]*$ ]]; then SKIPPED=$((SKIPPED + 1)) fail_row "$subscription_id" "$product_id" "$rate_limit" "rate_limit이 양의 정수가 아님" continue fi FRAGMENT_ID="rate-limit-${rate_limit}-per-sec" FRAGMENT_FILE="${FRAGMENTS_DIR}/${FRAGMENT_ID}.xml" # 방어 3: 로컬 정책조각 xml 파일 존재 확인 if [ ! -f "$FRAGMENT_FILE" ]; then SKIPPED=$((SKIPPED + 1)) fail_row "$subscription_id" "$product_id" "$rate_limit" "로컬 정책조각 파일 없음(${FRAGMENT_ID}.xml)" continue fi if [ "$SKIP_REMOTE_CHECK" -eq 0 ]; then # 방어 4: 제품이 실제 APIM에 존재하는지 확인 # (if 조건이 아니라 별도 호출 후 $?로 반환값을 봐야 1=404 / 2=기타오류를 구분할 수 있다. # "if ! remote_exists ...; then rc=$?"로 쓰면 $?가 remote_exists가 아니라 부정된 # 조건문 자체의 상태(항상 0)가 되어 원인 구분이 깨지므로 이렇게 쓰지 않는다.) if remote_exists "${APIM_BASE}/products/${product_id}?api-version=${PRODUCT_API_VERSION}"; then rc=0 else rc=$? fi if [ "$rc" -ne 0 ]; then if [ "$rc" -eq 1 ]; then SKIPPED=$((SKIPPED + 1)) fail_row "$subscription_id" "$product_id" "$rate_limit" "APIM에 제품 없음(product_id=${product_id})" else SKIPPED=$((SKIPPED + 1)) fail_row "$subscription_id" "$product_id" "$rate_limit" "제품 조회 오류: ${LAST_ERROR//$'\n'/ }" fi continue fi # 방어 5: 정책조각이 실제 APIM에 존재하는지 확인 if remote_exists "${APIM_BASE}/policyFragments/${FRAGMENT_ID}?api-version=${FRAGMENT_API_VERSION}"; then rc=0 else rc=$? fi if [ "$rc" -ne 0 ]; then if [ "$rc" -eq 1 ]; then SKIPPED=$((SKIPPED + 1)) fail_row "$subscription_id" "$product_id" "$rate_limit" "APIM에 정책조각 없음(${FRAGMENT_ID})" else SKIPPED=$((SKIPPED + 1)) fail_row "$subscription_id" "$product_id" "$rate_limit" "정책조각 조회 오류: ${LAST_ERROR//$'\n'/ }" fi continue fi fi if [ "$DRY_RUN" -eq 1 ]; then log " -> [dry-run] product_id=${product_id} 에 fragment-id=${FRAGMENT_ID} 적용 예정 (실제 적용 안 함)" SUCCESS=$((SUCCESS + 1)) continue fi # ---- 제품 정책 본문 구성 및 적용 ----------------------------------------- POLICY_XML=$(cat < XML ) ESCAPED_XML=$(json_escape "$POLICY_XML") BODY_FILE="$(mktemp)" printf '{"properties":{"format":"rawxml","value":"%s"}}' "$ESCAPED_XML" > "$BODY_FILE" if PUT_OUT=$(az rest --method PUT \ --uri "${APIM_BASE}/products/${product_id}/policies/policy?api-version=${PRODUCT_API_VERSION}" \ --headers "Content-Type=application/json" \ --body @"$BODY_FILE" --only-show-errors 2>&1); then SUCCESS=$((SUCCESS + 1)) log " -> 성공: product_id=${product_id} <- ${FRAGMENT_ID}" else SKIPPED=$((SKIPPED + 1)) fail_row "$subscription_id" "$product_id" "$rate_limit" "정책 적용 실패: ${PUT_OUT//$'\n'/ }" fi rm -f "$BODY_FILE" sleep "$DELAY_SECONDS" done < <(tail -n +2 "$CSV_FILE" | tr -d '\r') log "---" log "처리 완료: 총 ${TOTAL}건 / 성공 ${SUCCESS}건 / 실패(스킵) ${SKIPPED}건" log "로그 파일: ${LOG_FILE}" if [ "$SKIPPED" -gt 0 ]; then log "실패 목록: ${FAILED_CSV} (이 파일을 CSV 인자로 넘겨 재실행 가능)" exit 1 else rm -f "$FAILED_CSV" exit 0 fi