#!/usr/bin/env bash # # add-api-to-product.sh # Azure APIM에서 CSV에 정의된 Product에 API를 일괄 연결(add)한다. # # 입력 CSV 스키마 (long format, API 1건당 1행): # subscription_id,product_id,api_id # - subscription_id : 검증/로깅용 메타데이터 (실제 az 호출에는 사용하지 않음) # - product_id : APIM Product ID (이미 APIM에 생성되어 있어야 함) # - api_id : APIM API ID (이미 APIM에 생성되어 있어야 함) # # 사용법: # ./add-api-to-product.sh -c sample_input.csv -g rg-apim-prod -n apim-svc-prod # ./add-api-to-product.sh -c sample_input.csv -g rg-apim-prod -n apim-svc-prod --whatif # set -euo pipefail CSV_PATH="" RESOURCE_GROUP="" SERVICE_NAME="" LOG_PATH="" WHATIF=0 usage() { cat < -g -n [-l <로그경로>] [--whatif] 옵션: -c, --csv 입력 CSV 경로 (필수) -g, --resource-group APIM이 속한 리소스 그룹명 (필수) -n, --service-name APIM 서비스(인스턴스) 이름 (필수) -l, --log 결과 로그 CSV 경로 (기본: /result_YYYYmmdd_HHMMSS.csv) --whatif 실제 연결을 수행하지 않고 계획만 출력 (dry-run) -h, --help 도움말 출력 EOF } while [[ $# -gt 0 ]]; do case "$1" in -c|--csv) CSV_PATH="$2"; shift 2 ;; -g|--resource-group) RESOURCE_GROUP="$2"; shift 2 ;; -n|--service-name) SERVICE_NAME="$2"; shift 2 ;; -l|--log) LOG_PATH="$2"; shift 2 ;; --whatif) WHATIF=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "알 수 없는 옵션: $1" >&2; usage; exit 1 ;; esac done if [[ -z "$CSV_PATH" || -z "$RESOURCE_GROUP" || -z "$SERVICE_NAME" ]]; then echo "오류: -c, -g, -n 은 필수입니다." >&2 usage exit 1 fi if [[ ! -f "$CSV_PATH" ]]; then echo "오류: CSV 파일을 찾을 수 없습니다: $CSV_PATH" >&2 exit 1 fi if [[ -z "$LOG_PATH" ]]; then STAMP=$(date +%Y%m%d_%H%M%S) LOG_PATH="$(dirname "$CSV_PATH")/result_${STAMP}.csv" fi # --------------------------------------------------------------------------- # 1. az 로그인 및 APIM 서비스 존재 확인 (fail-fast) # --------------------------------------------------------------------------- echo "az CLI 로그인 상태 확인 중..." if ! az account show -o none 2>/dev/null; then echo "오류: az login 이 필요합니다. 'az login' 실행 후 다시 시도하세요." >&2 exit 1 fi echo "APIM 서비스 확인 중... ($RESOURCE_GROUP / $SERVICE_NAME)" if ! az apim show --resource-group "$RESOURCE_GROUP" --name "$SERVICE_NAME" -o none 2>/dev/null; then echo "오류: APIM 서비스를 찾을 수 없습니다 (resource-group: $RESOURCE_GROUP, name: $SERVICE_NAME). 이름/권한을 확인하세요." >&2 exit 1 fi # --------------------------------------------------------------------------- # 2. CSV 헤더에서 컬럼 위치 확인 (BOM/CRLF 제거) # --------------------------------------------------------------------------- HEADER=$(head -n1 "$CSV_PATH" | sed '1s/^\xEF\xBB\xBF//' | tr -d '\r') IFS=',' read -r -a COLS <<< "$HEADER" SUB_IDX=-1; PROD_IDX=-1; API_IDX=-1 for i in "${!COLS[@]}"; do case "${COLS[$i]}" in subscription_id) SUB_IDX=$i ;; product_id) PROD_IDX=$i ;; api_id) API_IDX=$i ;; esac done if [[ $SUB_IDX -lt 0 || $PROD_IDX -lt 0 || $API_IDX -lt 0 ]]; then echo "오류: CSV에 필수 컬럼(subscription_id, product_id, api_id)이 없습니다. 현재 헤더: $HEADER" >&2 exit 1 fi # --------------------------------------------------------------------------- # 3. 유효/중복 행 필터링 # --------------------------------------------------------------------------- TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT tail -n +2 "$CSV_PATH" | tr -d '\r' > "$TMP_DIR/body.csv" BODY_COUNT=$(wc -l < "$TMP_DIR/body.csv" | tr -d ' ') RAW_ROWS="$TMP_DIR/raw_rows.csv" awk -F',' -v subi="$SUB_IDX" -v prodi="$PROD_IDX" -v apii="$API_IDX" ' { s=$(subi+1); p=$(prodi+1); a=$(apii+1) gsub(/^[ \t]+|[ \t]+$/, "", s) gsub(/^[ \t]+|[ \t]+$/, "", p) gsub(/^[ \t]+|[ \t]+$/, "", a) if (s == "" || p == "" || a == "") next print s","p","a }' "$TMP_DIR/body.csv" > "$RAW_ROWS" TOTAL_RAW=$(wc -l < "$RAW_ROWS" | tr -d ' ') SKIPPED_INVALID=$(( BODY_COUNT - TOTAL_RAW )) if [[ $SKIPPED_INVALID -gt 0 ]]; then echo "경고: 빈 값이 있는 행 ${SKIPPED_INVALID}개를 건너뜁니다." fi # 중복 (product_id,api_id) 조합 제거 (product_id 기준 정렬 유지) VALID_ROWS="$TMP_DIR/valid_rows.csv" sort -t',' -k2,2 -k3,3 -u "$RAW_ROWS" | sort -t',' -k2,2 > "$VALID_ROWS" DUP_COUNT=$(( TOTAL_RAW - $(wc -l < "$VALID_ROWS" | tr -d ' ') )) if [[ $DUP_COUNT -gt 0 ]]; then echo "경고: 중복된 (product_id, api_id) 조합 ${DUP_COUNT}건을 제거했습니다." fi TOTAL_VALID=$(wc -l < "$VALID_ROWS" | tr -d ' ') PRODUCT_COUNT=$(cut -d',' -f2 "$VALID_ROWS" | sort -u | wc -l | tr -d ' ') echo "총 ${TOTAL_VALID}건 (product ${PRODUCT_COUNT}개) 처리 예정" # --------------------------------------------------------------------------- # 4. 결과 로그 # --------------------------------------------------------------------------- echo "subscription_id,product_id,api_id,status,message,timestamp" > "$LOG_PATH" log_result() { local sub="$1" prod="$2" api="$3" status="$4" message="$5" local ts ts=$(date +"%Y-%m-%dT%H:%M:%S") message=$(printf '%s' "$message" | tr -d '\r\n' | sed 's/"/""/g') echo "\"$sub\",\"$prod\",\"$api\",\"$status\",\"$message\",\"$ts\"" >> "$LOG_PATH" } # --------------------------------------------------------------------------- # 5. 방어 로직용 캐시/함수 # - product_exists / api_exists : 존재하지 않는 값을 add에 그대로 던지지 않고 # 사전에 걸러서 명확한 사유(status)로 기록한다. (같은 product/api가 # 여러 행에 걸쳐 재등장해도 az 호출은 한 번만 하도록 캐시) # - add_api_with_retry : 일시적 오류(네트워크/스로틀링)에 대비해 짧은 재시도 # --------------------------------------------------------------------------- declare -A PRODUCT_EXISTS_CACHE declare -A API_EXISTS_CACHE product_exists() { local pid="$1" if [[ -z "${PRODUCT_EXISTS_CACHE[$pid]+x}" ]]; then if az apim product show --resource-group "$RESOURCE_GROUP" --service-name "$SERVICE_NAME" \ --product-id "$pid" -o none 2>/dev/null; then PRODUCT_EXISTS_CACHE[$pid]=1 else PRODUCT_EXISTS_CACHE[$pid]=0 fi fi [[ "${PRODUCT_EXISTS_CACHE[$pid]}" == "1" ]] } api_exists() { local aid="$1" if [[ -z "${API_EXISTS_CACHE[$aid]+x}" ]]; then if az apim api show --resource-group "$RESOURCE_GROUP" --service-name "$SERVICE_NAME" \ --api-id "$aid" -o none 2>/dev/null; then API_EXISTS_CACHE[$aid]=1 else API_EXISTS_CACHE[$aid]=0 fi fi [[ "${API_EXISTS_CACHE[$aid]}" == "1" ]] } LAST_ERROR="" add_api_with_retry() { local prod="$1" api="$2" local max_attempts=3 attempt=1 output while (( attempt <= max_attempts )); do if output=$(az apim product api add \ --resource-group "$RESOURCE_GROUP" \ --service-name "$SERVICE_NAME" \ --product-id "$prod" \ --api-id "$api" \ -o none 2>&1); then LAST_ERROR="" return 0 fi LAST_ERROR="$output" if (( attempt < max_attempts )); then echo " (일시 오류로 ${attempt}번째 시도 실패, $((attempt * 2))s 후 재시도)" sleep $((attempt * 2)) fi attempt=$((attempt + 1)) done return 1 } # --------------------------------------------------------------------------- # 6. product별 그룹 처리 # --------------------------------------------------------------------------- PRODUCTS=$(cut -d',' -f2 "$VALID_ROWS" | sort -u) P_IDX=0 while IFS= read -r PRODUCT_ID; do [[ -z "$PRODUCT_ID" ]] && continue P_IDX=$((P_IDX + 1)) ROWS_FOR_PRODUCT=$(awk -F',' -v p="$PRODUCT_ID" '$2==p' "$VALID_ROWS") ROW_COUNT=$(printf '%s\n' "$ROWS_FOR_PRODUCT" | grep -c . || true) echo "[${P_IDX}/${PRODUCT_COUNT}] product '${PRODUCT_ID}' 처리 중... (API ${ROW_COUNT}건)" # 방어 1: product 자체가 APIM에 없으면 이 product의 모든 행을 명확한 사유로 남기고 건너뜀 if ! product_exists "$PRODUCT_ID"; then echo " ! product '${PRODUCT_ID}' 가 APIM에 존재하지 않습니다. (skip)" while IFS=',' read -r SUB PROD API; do [[ -z "$API" ]] && continue log_result "$SUB" "$PROD" "$API" "product-not-found" "product가 APIM에 존재하지 않음" done <<< "$ROWS_FOR_PRODUCT" continue fi # 기존에 연결된 API 목록 조회 (idempotency: 이미 연결된 건 skip) EXISTING_APIS=$(az apim product api list \ --resource-group "$RESOURCE_GROUP" \ --service-name "$SERVICE_NAME" \ --product-id "$PRODUCT_ID" \ --query "[].name" -o tsv 2>/dev/null || true) while IFS=',' read -r SUB PROD API; do [[ -z "$API" ]] && continue if grep -qxF "$API" <<< "$EXISTING_APIS"; then echo " - ${API} : 이미 연결됨 (skip)" log_result "$SUB" "$PROD" "$API" "skipped-exists" "이미 연결되어 있음" continue fi # 방어 2: api 자체가 APIM에 없으면 add를 시도하지 않고 명확한 사유로 남김 if ! api_exists "$API"; then echo " - ${API} : APIM에 존재하지 않는 API (skip)" log_result "$SUB" "$PROD" "$API" "api-not-found" "api가 APIM에 존재하지 않음" continue fi if [[ $WHATIF -eq 1 ]]; then echo " - ${API} : dry-run (WhatIf)" log_result "$SUB" "$PROD" "$API" "dryrun" "WhatIf: 실제 실행 안 함" continue fi # 방어 3: 일시적 오류는 짧게 재시도 후 최종 실패만 failed로 기록 if add_api_with_retry "$PROD" "$API"; then echo " - ${API} : 성공" log_result "$SUB" "$PROD" "$API" "success" "연결 성공" else echo " - ${API} : 실패 - ${LAST_ERROR}" log_result "$SUB" "$PROD" "$API" "failed" "$LAST_ERROR" fi done <<< "$ROWS_FOR_PRODUCT" done <<< "$PRODUCTS" # --------------------------------------------------------------------------- # 7. 요약 # --------------------------------------------------------------------------- echo "" echo "===== 결과 요약 =====" tail -n +2 "$LOG_PATH" | awk -F'","' '{print $4}' | sort | uniq -c | sort -rn echo "상세 로그: $LOG_PATH" FAILED_COUNT=$(grep -c ',"failed",' "$LOG_PATH" || true) NOT_FOUND_COUNT=$(( $(grep -c ',"product-not-found",' "$LOG_PATH" || true) + $(grep -c ',"api-not-found",' "$LOG_PATH" || true) )) if [[ $NOT_FOUND_COUNT -gt 0 ]]; then echo "경고: 존재하지 않는 product/api 참조가 ${NOT_FOUND_COUNT}건 있습니다. CSV의 product_id/api_id 값을 확인하세요." >&2 fi if [[ $FAILED_COUNT -gt 0 || $NOT_FOUND_COUNT -gt 0 ]]; then echo "경고: 재검토가 필요한 행이 있습니다. ${LOG_PATH} 에서 status가 failed/product-not-found/api-not-found인 행을 확인 후 재실행하세요." >&2 exit 1 fi