diff --git a/.github/actions/deploy-to-ec2/action.yml b/.github/actions/deploy-to-ec2/action.yml new file mode 100644 index 0000000..16def30 --- /dev/null +++ b/.github/actions/deploy-to-ec2/action.yml @@ -0,0 +1,65 @@ +name: Deploy to EC2 +description: Validate config, setup SSH, upload files, execute remote command + +inputs: + ssh-private-key: + required: true + description: SSH key for remote machine + host: + required: true + description: SSH host + username: + required: true + description: SSH username + files: + description: Space-separated list of local files to upload + required: false + remote-command: + description: Command to execute on the remote host + required: true + +runs: + using: composite + steps: + - name: 설정값 누락 확인 + shell: bash + env: + _SSH_PRIVATE_KEY: ${{ inputs.ssh-private-key }} + _HOST: ${{ inputs.host }} + _USERNAME: ${{ inputs.username }} + _REMOTE_COMMAND: ${{ inputs.remote-command }} + run: | + missing=() + for var in _SSH_PRIVATE_KEY _HOST _USERNAME _REMOTE_COMMAND; do + if [ -z "${!var}" ]; then + missing+=("${var#_}") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Missing: ${missing[*]}" + exit 1 + fi + echo "모든 필수 설정값이 확인되었습니다." + + - name: SSH 연결 준비 + shell: bash + run: | + mkdir -p ~/.ssh + echo "${{ inputs.ssh-private-key }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "${{ inputs.host }}" >> ~/.ssh/known_hosts 2>/dev/null + + - name: 파일 업로드 + if: ${{ inputs.files != '' }} + shell: bash + run: | + scp -i ~/.ssh/deploy_key \ + ${{ inputs.files }} \ + "${{ inputs.username }}@${{ inputs.host }}:/app/" + + - name: 원격 명령 실행 + shell: bash + run: | + ssh -i ~/.ssh/deploy_key \ + "${{ inputs.username }}@${{ inputs.host }}" \ + "${{ inputs.remote-command }}" diff --git a/.github/actions/notify-failure/action.yml b/.github/actions/notify-failure/action.yml new file mode 100644 index 0000000..5297453 --- /dev/null +++ b/.github/actions/notify-failure/action.yml @@ -0,0 +1,25 @@ +name: Notify Failure +description: Send failure notification via webhook + +inputs: + webhook-url: + description: Notification webhook URL (optional - skips if empty) + required: false + message: + description: Failure message to send + required: true + +runs: + using: composite + steps: + - name: 실패 알림 + shell: bash + env: + WEBHOOK_URL: ${{ inputs.webhook-url }} + MESSAGE: ${{ inputs.message }} + run: | + if [ -n "$WEBHOOK_URL" ]; then + curl -s -X POST "$WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "{\"text\":\"${MESSAGE}\"}" + fi diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6cac0dc..2fbe5a0 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,4 +1,4 @@ -name: CD +name: Sofia CD Pipeline on: push: @@ -11,6 +11,11 @@ concurrency: permissions: contents: write +# 이 파이프라인이 필요로 하는 설정값 목록: +# Required Variables (비민감): EC2_HOST, EC2_USERNAME +# Required Secrets (민감): EC2_SSH_PRIVATE_KEY, SOFIA_DATASOURCE_URL, SOFIA_DATASOURCE_PASSWORD, SOFIA_DATASOURCE_USERNAME +# Optional Secrets (민감): NOTIFICATION_WEBHOOK_URL + jobs: deploy: runs-on: ubuntu-latest @@ -24,63 +29,53 @@ jobs: distribution: temurin java-version: "21" - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ hashFiles('**/*.gradle.kts', 'gradle.properties') }} - restore-keys: gradle- + - uses: gradle/actions/setup-gradle@v4 - - name: Read version + - name: 버전 읽기 id: version run: | VERSION=$(grep 'ywcheong.sofia.version=' gradle.properties | cut -d'=' -f2) echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Build + - name: 빌드 run: ./gradlew build - - name: Create and push tag - run: | - git tag "v${{ steps.version.outputs.version }}" - git push origin "v${{ steps.version.outputs.version }}" - - - name: Setup SSH + - name: 시크릿 환경변수 파일 생성 + env: + SOFIA_DATASOURCE_URL: ${{ secrets.SOFIA_DATASOURCE_URL }} + SOFIA_DATASOURCE_PASSWORD: ${{ secrets.SOFIA_DATASOURCE_PASSWORD }} + SOFIA_DATASOURCE_USERNAME: ${{ secrets.SOFIA_DATASOURCE_USERNAME }} run: | - mkdir -p ~/.ssh - echo "${{ secrets.EC2_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H "${{ secrets.EC2_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null + { + echo "SOFIA_DATASOURCE_URL=${SOFIA_DATASOURCE_URL}" + echo "SOFIA_DATASOURCE_PASSWORD=${SOFIA_DATASOURCE_PASSWORD}" + echo "SOFIA_DATASOURCE_USERNAME=${SOFIA_DATASOURCE_USERNAME}" + } > deploy/secrets.env - - name: Upload jar to EC2 - run: | - scp -i ~/.ssh/deploy_key \ - "build/libs/sofia-${{ steps.version.outputs.version }}.jar" \ - "${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:${{ secrets.EC2_DEPLOY_DIR }}/" + - name: EC2 배포 + uses: ./.github/actions/deploy-to-ec2 + with: + ssh-private-key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + host: ${{ vars.EC2_HOST }} + username: ${{ vars.EC2_USERNAME }} + files: deploy/deploy.sh deploy/secrets.env build/libs/sofia-${{ steps.version.outputs.version }}.jar + remote-command: "/app/deploy.sh '/app/sofia-${{ steps.version.outputs.version }}.jar'" - - name: Run deploy script + - name: Git 태그 생성 및 푸시 run: | - ssh -i ~/.ssh/deploy_key \ - "${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}" \ - "${{ secrets.EC2_DEPLOY_SCRIPT }} '${{ secrets.EC2_DEPLOY_DIR }}/sofia-${{ steps.version.outputs.version }}.jar'" + git tag "v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" - - name: Create GitHub Release + - name: GitHub Release 생성 uses: softprops/action-gh-release@v2 with: tag_name: "v${{ steps.version.outputs.version }}" files: build/libs/sofia-${{ steps.version.outputs.version }}.jar generate_release_notes: true - - name: Notify on failure + - name: 실패 알림 if: failure() - env: - WEBHOOK_URL: ${{ secrets.NOTIFICATION_WEBHOOK_URL }} - VERSION: ${{ steps.version.outputs.version }} - RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - run: | - if [ -n "$WEBHOOK_URL" ]; then - curl -s -X POST "$WEBHOOK_URL" \ - -H 'Content-Type: application/json' \ - -d "{\"text\":\"Sofia v${VERSION} 배포 실패\n${RUN_URL}\"}" - fi + uses: ./.github/actions/notify-failure + with: + webhook-url: ${{ secrets.NOTIFICATION_WEBHOOK_URL }} + message: "Sofia v${{ steps.version.outputs.version }} 배포 실패\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86fe615..11f4f9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Sofia CI Pipeline on: pull_request: @@ -15,13 +15,15 @@ jobs: distribution: temurin java-version: "21" - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ hashFiles('**/*.gradle.kts', 'gradle.properties') }} - restore-keys: gradle- + - uses: gradle/actions/setup-gradle@v4 + + - name: 버전 태그 중복 확인 + run: | + VERSION=$(grep 'ywcheong.sofia.version=' gradle.properties | cut -d'=' -f2) + if git ls-remote --tags origin "refs/tags/v${VERSION}" | grep -q .; then + echo "::error::Tag v${VERSION} already exists. Update ywcheong.sofia.version in gradle.properties." + exit 1 + fi - - name: Run checks + - name: 검사 수행 run: ./gradlew check diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml new file mode 100644 index 0000000..d5b8739 --- /dev/null +++ b/.github/workflows/init.yml @@ -0,0 +1,43 @@ +name: Sofia Remote Init (Run only once) + +on: + workflow_dispatch: + +concurrency: + group: init + cancel-in-progress: false + +# 이 파이프라인이 필요로 하는 설정값 목록: +# Required Variables (비민감): EC2_HOST, EC2_USERNAME +# Required Secrets (민감): EC2_SSH_PRIVATE_KEY +# Optional Secrets (민감): NOTIFICATION_WEBHOOK_URL + +jobs: + init: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 인증서 파일 생성 + env: + CF_CERT: ${{ secrets.CLOUDFLARE_CERTS_CERT }} + CF_KEY: ${{ secrets.CLOUDFLARE_CERTS_KEY }} + run: | + printf '%s' "$CF_CERT" > deploy/cert.pem + printf '%s' "$CF_KEY" > deploy/key.pem + + - name: EC2 초기화 + uses: ./.github/actions/deploy-to-ec2 + with: + ssh-private-key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + host: ${{ vars.EC2_HOST }} + username: ${{ vars.EC2_USERNAME }} + files: deploy/init.sh deploy/nginx-sofia.conf deploy/cert.pem deploy/key.pem + remote-command: "bash /app/init.sh" + + - name: 실패 알림 + if: failure() + uses: ./.github/actions/notify-failure + with: + webhook-url: ${{ secrets.NOTIFICATION_WEBHOOK_URL }} + message: "Sofia 초기화 실패\n${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/deploy/deploy.sh b/deploy/deploy.sh index b63e682..acfa387 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -5,35 +5,83 @@ JAR_PATH="$1" PID_FILE="/app/sofia.pid" LOG_DIR="/app/log" -# 기존 프로세스 종료 -if [ -f "$PID_FILE" ]; then - OLD_PID=$(cat "$PID_FILE") - if kill -0 "$OLD_PID" 2>/dev/null; then - echo "Stopping existing process (PID: $OLD_PID)" - kill "$OLD_PID" - timeout 30 bash -c "while kill -0 $OLD_PID 2>/dev/null; do sleep 1; done" || true +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/secrets.env" + +stop_existing_process() { + if [ ! -f "$PID_FILE" ]; then + return + fi + + local old_pid + old_pid=$(cat "$PID_FILE") + + if kill -0 "$old_pid" 2>/dev/null; then + echo "Stopping existing process (PID: $old_pid)" + kill "$old_pid" + timeout 30 bash -c "while kill -0 $old_pid 2>/dev/null; do sleep 1; done" || true fi + rm -f "$PID_FILE" -fi +} -# 로그 디렉토리 생성 -mkdir -p "$LOG_DIR" +build_log_file() { + mkdir -p "$LOG_DIR" + local version + version=$(basename "$JAR_PATH" | sed 's/sofia-\(.*\)\.jar/\1/') + echo "${LOG_DIR}/$(date +%Y%m%d-%H%M%S)-${version}.log" +} -# 로그 파일명: {launch-date}-{version}.log -VERSION=$(basename "$JAR_PATH" | sed 's/sofia-\(.*\)\.jar/\1/') -LOG_FILE="${LOG_DIR}/$(date +%Y%m%d-%H%M%S)-${VERSION}.log" +refresh_secrets_from_env() { + local secret_keys=("SOFIA_DATASOURCE_URL" "SOFIA_DATASOURCE_PASSWORD" "SOFIA_DATASOURCE_USERNAME") + local available=false -# 환경변수 로드 후 실행 -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ENV_FILE="${SCRIPT_DIR}/secrets.env" + for key in "${secret_keys[@]}"; do + if [ -n "${!key:-}" ]; then + available=true + break + fi + done + + if [ "$available" = true ]; then + echo "Refreshing secrets.env from environment variables" + > "$ENV_FILE" + for key in "${secret_keys[@]}"; do + if [ -n "${!key:-}" ]; then + echo "${key}=${!key}" >> "$ENV_FILE" + fi + done + fi +} + +load_env_file() { + if [ -f "$ENV_FILE" ]; then + set -a + source "$ENV_FILE" + set +a + fi +} + +start_application() { + local log_file="$1" + local version + version=$(basename "$JAR_PATH" | sed 's/sofia-\(.*\)\.jar/\1/') + + echo "Starting Sofia v${version}" + java \ + -Xms128m -Xmx256m \ + -XX:MetaspaceSize=64m \ + -XX:MaxMetaspaceSize=128m \ + -XX:+UseSerialGC \ + -jar "$JAR_PATH" > "$log_file" 2>&1 & -if [ -f "$ENV_FILE" ]; then - set -a - source "$ENV_FILE" - set +a -fi + echo $! > "$PID_FILE" + echo "Started (PID: $(cat "$PID_FILE"), Log: $log_file)" +} -echo "Starting Sofia v${VERSION}" -java -jar "$JAR_PATH" > "$LOG_FILE" 2>&1 & -echo $! > "$PID_FILE" -echo "Started (PID: $(cat "$PID_FILE"), Log: $LOG_FILE)" +# --- Main --- +stop_existing_process +log_file=$(build_log_file) +refresh_secrets_from_env +load_env_file +start_application "$log_file" diff --git a/deploy/init.sh b/deploy/init.sh index cb6ce56..c4f2b9f 100755 --- a/deploy/init.sh +++ b/deploy/init.sh @@ -1,8 +1,30 @@ #!/usr/bin/env bash set -euo pipefail -echo "Installing JDK 21 on Amazon Linux 2023..." -sudo dnf install -y java-21-amazon-corretto-devel +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -java -version +install_jdk() { + echo "Installing JDK 21 on Amazon Linux 2023..." + sudo dnf install -y java-21-amazon-corretto-devel + java -version +} + +install_and_configure_nginx() { + echo "Installing nginx..." + sudo dnf install -y nginx + + echo "Copying nginx config..." + sudo cp "${SCRIPT_DIR}/nginx-sofia.conf" /etc/nginx/conf.d/sofia.conf + sudo rm -f /etc/nginx/conf.d/welcome.conf /etc/nginx/sites-enabled/default 2>/dev/null || true + + echo "Validating nginx config..." + sudo nginx -t + + echo "Enabling and starting nginx..." + sudo systemctl enable --now nginx +} + +# --- Main --- +install_jdk +install_and_configure_nginx echo "Done." diff --git a/deploy/nginx-sofia.conf b/deploy/nginx-sofia.conf new file mode 100644 index 0000000..e0fb755 --- /dev/null +++ b/deploy/nginx-sofia.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name _; + + ssl_certificate /app/cert.pem; + ssl_certificate_key /app/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/gradle.properties b/gradle.properties index a4e98c8..d064203 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.console=plain org.gradle.logging.level=quiet org.gradle.warning.mode=summary -ywcheong.sofia.version=26b.04.01 +ywcheong.sofia.version=26b.04.01.1 ywcheong.sofia.jdk_version=21 \ No newline at end of file diff --git a/src/main/kotlin/ywcheong/sofia/config/security/CorsProperties.kt b/src/main/kotlin/ywcheong/sofia/config/security/CorsProperties.kt new file mode 100644 index 0000000..23570b0 --- /dev/null +++ b/src/main/kotlin/ywcheong/sofia/config/security/CorsProperties.kt @@ -0,0 +1,8 @@ +package ywcheong.sofia.config.security + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "sofia.cors") +data class CorsProperties( + val allowedOrigins: List, +) diff --git a/src/main/kotlin/ywcheong/sofia/config/security/SecurityConfiguration.kt b/src/main/kotlin/ywcheong/sofia/config/security/SecurityConfiguration.kt index fa38f03..12b33ff 100644 --- a/src/main/kotlin/ywcheong/sofia/config/security/SecurityConfiguration.kt +++ b/src/main/kotlin/ywcheong/sofia/config/security/SecurityConfiguration.kt @@ -1,5 +1,6 @@ package ywcheong.sofia.config.security +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity @@ -8,19 +9,25 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.access.ExceptionTranslationFilter +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource import ywcheong.sofia.config.mvc.GlobalExceptionHandler @Configuration @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) +@EnableConfigurationProperties(CorsProperties::class) class SecurityConfiguration( private val requestLoggingFilter: RequestLoggingFilter, private val apiKeyAuthFilter: ApiKeyAuthFilter, private val securityExceptionHandler: GlobalExceptionHandler.SecurityExceptionHandler, + private val corsProperties: CorsProperties, ) { @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { return http + .cors { it.configurationSource(corsConfigurationSource()) } .csrf { it.disable() } .httpBasic { it.disable() } .formLogin { it.disable() } @@ -38,4 +45,16 @@ class SecurityConfiguration( ) .build() } + + private fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration().apply { + allowedOrigins = corsProperties.allowedOrigins + allowedMethods = listOf("*") + allowedHeaders = listOf("*") + allowCredentials = true + } + return UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("/**", configuration) + } + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 99e67ab..59e24d6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,3 +36,6 @@ sofia: from-name: KSA 국제부 번역버디 ERP dummy: enabled: false + cors: + allowed-origins: + - https://sofia.ywcheong.com