diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d851b3408 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Build output +build/ +!build/libs/ + +# IDE files +.idea/ +*.iml +*.iws + +# OS files +.DS_Store +Thumbs.db + +# Version control +.git/ +.gitignore + +# Documentation +README.md +HELP.md + +# Other directories +admin/ +.github/ + +# Gradle cache +.gradle/ + +# Test results +test-results/ + +# Temporary files +*.tmp +*.log + +.env +discodeit.env \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..b944f5644 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,106 @@ +name: Deploy to AWS ECS + +on: + push: + branches: [ release ] + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + outputs: + image: ${{ steps.build-image.outputs.image }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: us-east-1 + + - name: Login to Amazon ECR (Public) + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + with: + registry-type: public + + - name: Build, tag and push image to Amazone ECR + id: build-image + env: + ECR_REPOSITORY_URI: ${{ vars.ECR_REPOSITORY_URI }} + IMAGE_TAG: ${{ github.sha }} + run: | + chmod +x ./gradlew + ./gradlew bootJar + + # Docker 이미지 빌드 (x86_64 아키텍처) + docker build --platform linux/amd64 \ + -t $ECR_REPOSITORY_URI:$IMAGE_TAG \ + -t $ECR_REPOSITORY_URI:latest . + + # 이미지 푸시 + docker push $ECR_REPOSITORY_URI:$IMAGE_TAG + docker push $ECR_REPOSITORY_URI:latest + + echo "image=$ECR_REPOSITORY_URI:$IMAGE_TAG" >> $GITHUB_OUTPUT + + deploy: + name: Deploy to ECS + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS credentials for ECS + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: discodeit-app + image: ${{ needs.build-and-push.outputs.image }} + + - name: Stop existing ECS service + run: | + aws ecs update-service \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.ECS_SERVICE }} \ + --desired-count 0 + + aws ecs wait services-stable \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --services ${{ vars.ECS_SERVICE }} + + - name: Deploy to Amazon ECS + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + cluster: ${{ vars.ECS_CLUSTER }} + service: ${{ vars.ECS_SERVICE }} + wait-for-service-stability: true + + - name: Start ECS service + run: | + aws ecs update-service \ + --cluster ${{ vars.ECS_CLUSTER }} \ + --service ${{ vars.ECS_SERVICE }}\ + --desired-count 1 \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..6be7617cc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Java CI (Gradle + Codecov) + +on: + # main 브랜치 대상으로 만든 PR 에서만 실행 + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + env: + SPRING_PROFILES_ACTIVE: test + + steps: + # 1. 소스 체크아웃 + - name: Source CheckOut + uses: actions/checkout@v4 + + # 2. JDK 17 설치 + Gradle 캐시 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle # Gradle 종속성 캐싱 설정 + + # 3. Gradle wrapper 실행 권한 부여 (Linux/macOS용) + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # 4. GitHub Actions 시크릿 및 변수를 통한 .env 생성 + - name: Generate .env file + run: | + echo "AWS_S3_ACCESS_KEY=${{ secrets.AWS_ACCESS_KEY }}" >> .env + echo "AWS_S3_SECRET_KEY=${{ secrets.AWS_SECRET_KEY }}" >> .env + echo "AWS_S3_BUCKET=${{ secrets.AWS_S3_BUCKET }}" >> .env + echo "AWS_S3_REGION=${{ vars.AWS_REGION }}" >> .env + echo "AWS_S3_PRESIGNED_URL_EXPIRATION=600" >> .env + + # 5. 단위 테스트 + JaCoCo 커버리지 리포트 생성 + - name: Run tests + run: | + ./gradlew clean test jacocoTestReport + + # 6. Codecov 로 커버리지 업로드 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: build/reports/jacoco/test/jacocoTestReport.xml + fail_ci_if_error: true # 업로드 실패 시 워크플로 실패 처리 \ No newline at end of file diff --git a/.gitignore b/.gitignore index a88dda34a..e75c630ca 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,22 @@ bin/ .storage ### Logs ### -logs \ No newline at end of file +logs + +# Environment variables +.env +.env.local +.env.*.local +discodeit.env + +# Docker +docker-compose.override.yml +docker-compose.yml + +### AWS 관련 민감 파일들 ### +*.pem +*.key +aws-credentials.txt +.aws/ +credentials +config \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index b018b2fc9..0ead08afa 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -12,14 +12,6 @@ - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..1d5831c71 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# === 1단계: 빌드 전용 이미지 === +FROM amazoncorretto:17 AS builder + +WORKDIR /app + +# Gradle Wrapper 및 프로젝트 소스 복사 +COPY gradlew gradlew.bat ./ +COPY gradle ./gradle +COPY build.gradle settings.gradle ./ +COPY src ./src + +RUN chmod +x ./gradlew +RUN ./gradlew clean build -x test + +# === 2단계: 실행 전용 이미지 === +FROM amazoncorretto:17 + +WORKDIR /app + +# 실행에 필요한 jar 파일만 복사 +ARG PROJECT_NAME=discodeit +ARG PROJECT_VERSION=1.2-M8 +COPY --from=builder /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}.jar app.jar + +# 포트 노출 +EXPOSE 80 + +# 환경변수 설정 +ENV JVM_OPTS="" + +# 애플리케이션 실행 +CMD ["sh", "-c", "java $JVM_OPTS -jar app.jar --spring.profiles.active=prod --server.port=80"] diff --git a/README.md b/README.md index 914230f9c..5f5954268 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Pure - Discord Clone Application +# Pure - Discord Clone Application [![codecov](https://codecov.io/gh/pureod/3-sprint-mission/branch/sprint8/graph/badge.svg?token=4IDIFWEUFI)](https://codecov.io/gh/pureod/3-sprint-mission) Spring Boot 기반의 실시간 채팅 애플리케이션입니다. Discord와 유사한 기능들을 제공하는 RESTful API 서버입니다. diff --git a/admin/.gitattributes b/admin/.gitattributes deleted file mode 100644 index 8af972cde..000000000 --- a/admin/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -/gradlew text eol=lf -*.bat text eol=crlf -*.jar binary diff --git a/admin/.gitignore b/admin/.gitignore deleted file mode 100644 index c2065bc26..000000000 --- a/admin/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/admin/build.gradle b/admin/build.gradle deleted file mode 100644 index c6b7f29aa..000000000 --- a/admin/build.gradle +++ /dev/null @@ -1,38 +0,0 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '3.5.3' - id 'io.spring.dependency-management' version '1.1.7' -} - -group = 'com.sprint.mission' -version = '0.0.1-SNAPSHOT' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -ext { - set('springBootAdminVersion', "3.5.0") -} - -dependencies { - implementation 'de.codecentric:spring-boot-admin-starter-server' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -dependencyManagement { - imports { - mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}" - } -} - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/admin/gradle/wrapper/gradle-wrapper.jar b/admin/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55ba..000000000 Binary files a/admin/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/admin/gradle/wrapper/gradle-wrapper.properties b/admin/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ff23a68d7..000000000 --- a/admin/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/admin/gradlew b/admin/gradlew deleted file mode 100644 index 23d15a936..000000000 --- a/admin/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH="\\\"\\\"" - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/admin/gradlew.bat b/admin/gradlew.bat deleted file mode 100644 index db3a6ac20..000000000 --- a/admin/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH= - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/admin/settings.gradle b/admin/settings.gradle deleted file mode 100644 index e83a19cde..000000000 --- a/admin/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'admin' diff --git a/admin/src/main/java/com/sprint/mission/admin/AdminApplication.java b/admin/src/main/java/com/sprint/mission/admin/AdminApplication.java deleted file mode 100644 index 9245ccc8a..000000000 --- a/admin/src/main/java/com/sprint/mission/admin/AdminApplication.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sprint.mission.admin; - -import de.codecentric.boot.admin.server.config.EnableAdminServer; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -@EnableAdminServer -public class AdminApplication { - - public static void main(String[] args) { - SpringApplication.run(AdminApplication.class, args); - } - -} diff --git a/admin/src/main/resources/application.yaml b/admin/src/main/resources/application.yaml deleted file mode 100644 index 0e146222d..000000000 --- a/admin/src/main/resources/application.yaml +++ /dev/null @@ -1,5 +0,0 @@ -spring: - application: - name: admin -server: - port: 9090 diff --git a/admin/src/test/java/com/sprint/mission/admin/AdminApplicationTests.java b/admin/src/test/java/com/sprint/mission/admin/AdminApplicationTests.java deleted file mode 100644 index 490b85e74..000000000 --- a/admin/src/test/java/com/sprint/mission/admin/AdminApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.sprint.mission.admin; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class AdminApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/build.gradle b/build.gradle index c7bd6091f..3c702bdc9 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ jacocoTestReport { } group = 'com.sprint.mission' -version = '0.0.1-SNAPSHOT' +version = '1.2-M8' java { toolchain { @@ -58,8 +58,7 @@ dependencies { implementation 'org.slf4j:slf4j-api' - implementation 'de.codecentric:spring-boot-admin-starter-client:3.4.5' - + implementation 'software.amazon.awssdk:s3:2.31.7' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java new file mode 100644 index 000000000..030693a6f --- /dev/null +++ b/src/main/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorage.java @@ -0,0 +1,149 @@ +package com.sprint.mission.discodeit.storage.s3; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import com.sprint.mission.discodeit.storage.BinaryContentStorage; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; +import java.util.NoSuchElementException; +import java.util.UUID; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +@Log4j2 +@Component +@ConditionalOnProperty(name = "discodeit.storage.type", havingValue = "s3") +public class S3BinaryContentStorage implements BinaryContentStorage { + + private final String accessKey; + private final String secretKey; + private final String region; + private final String bucket; + + public S3BinaryContentStorage( + @Value("${discodeit.storage.s3.access-key}") String accessKey, + @Value("${discodeit.storage.s3.secret-key}") String secretKey, + @Value("${discodeit.storage.s3.region}") String region, + @Value("${discodeit.storage.s3.bucket}") String bucket + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucket = bucket; + } + + @Override + public UUID put(UUID binaryContentId, byte[] bytes) { + + try (S3Client s3Client = getS3Client()) { + PutObjectResponse response = s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(binaryContentId.toString()) + .build(), + RequestBody.fromBytes(bytes) + ); + + if (response.eTag() == null || response.eTag().isEmpty()) { + throw new RuntimeException("Failed to upload file to S3"); + } + } + + return binaryContentId; + + } + + @Override + public InputStream get(UUID binaryContentId) { + + try { + return getS3Client().getObject(GetObjectRequest.builder() + .bucket(bucket) + .key(binaryContentId.toString()) + .build()); + + } catch (S3Exception s3e) { + if ("NoSuchKey".equals(s3e.awsErrorDetails().errorCode())) { + throw new NoSuchElementException( + "S3 object not found: " + binaryContentId, s3e); + } + throw s3e; + } + + } + + @Override + public ResponseEntity download(BinaryContentDto metaData) { + + try (InputStream ignored = get(metaData.id())) { + log.info("S3 filed exist - fileId: {}", metaData.id()); + } catch (IOException e) { + throw new RuntimeException("Failed to verify S3 object", e); + } + + String presignedUrl = generatePresignedUrl(metaData.id().toString(), + metaData.contentType()); + + log.info("S3 download - presignedUrl: {}", presignedUrl); + + return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT) + .header(HttpHeaders.LOCATION, presignedUrl) + .build(); + + } + + private S3Client getS3Client() { + + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } + + private String generatePresignedUrl(String key, String contentType) { + + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + try (S3Presigner presigner = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build()) { + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(600)) + .getObjectRequest(GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .responseContentType(contentType) + .build()) + .build(); + + URI presignedUri = presigner.presignGetObject(presignRequest).url().toURI(); + return presignedUri.toString(); + + } catch (Exception e) { + throw new RuntimeException("Error generating presigned URL", e); + } + } + +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 619e2175d..f59af14db 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -2,11 +2,6 @@ spring: application: name: discodeit - boot: - admin: - client: - url: http://localhost:9090 - config: activate: on-profile: dev diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 2ddac9ad6..19596603d 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -2,19 +2,14 @@ spring: application: name: discodeit - boot: - admin: - client: - url: ${SPRING_BOOT_ADMIN_CLIENT_URL} - config: activate: on-profile: prod datasource: - url: jdbc:postgresql://localhost:5432/discodeit - username: discodeit_user - password: discodeit1234 + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} driver-class-name: org.postgresql.Driver jpa: @@ -41,4 +36,4 @@ logging: org.hibernate.orm.jdbc.bind: off server: - port: 8081 + port: 80 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 055e76565..866ef64e8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -2,12 +2,6 @@ spring: application: name: discodeit - boot: - admin: - client: - instance: - name: discodeit - profiles: active: prod # 기본은 dev, 운영 시 prod로 교체 @@ -29,13 +23,15 @@ spring: discodeit: storage: - type: local + type: ${STORAGE_TYPE:local} local: - root-path: ./.storage - -storage: - type: local - path: ./uploads + root-path: ${STORAGE_LOCAL_ROOT_PATH:./.storage} + s3: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + presigned-url-expiration: ${AWS_S3_PRESIGNED_URL_EXPIRATION} logging: level: diff --git a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java index 3a987a214..527261a22 100644 --- a/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java +++ b/src/test/java/com/sprint/mission/discodeit/DiscodeitApplicationTests.java @@ -2,12 +2,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class DiscodeitApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java new file mode 100644 index 000000000..6cda4e44d --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/AWSS3Test.java @@ -0,0 +1,136 @@ +package com.sprint.mission.discodeit.storage.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Properties; + +import org.junit.jupiter.api.*; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AWSS3Test { + + private S3Client s3; + private S3Presigner presigner; + private String bucket; + + private static final String KEY = "sampleTest-ljy.txt"; + + /** + * .env 로드 및 클라이언트 생성 + */ + @BeforeAll + void setUp() throws IOException { + Properties env = new Properties(); + env.load(Files.newBufferedReader(Path.of(".env"))); + + AwsBasicCredentials credentials = AwsBasicCredentials.create( + env.getProperty("AWS_S3_ACCESS_KEY"), + env.getProperty("AWS_S3_SECRET_KEY")); + + Region region = Region.of(env.getProperty("AWS_S3_REGION")); + this.bucket = env.getProperty("AWS_S3_BUCKET"); + + this.s3 = S3Client.builder() + .region(region) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + + this.presigner = S3Presigner.builder() + .region(region) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } + + /** + * 리소스 정리 + */ + @AfterAll + void tearDown() { + if (s3 != null) { + s3.close(); + } + if (presigner != null) { + presigner.close(); + } + } + + /** + * 1) 업로드 + */ + @Test + @DisplayName("S3 파일 업로드 성공") + void upload() throws IOException { + Path tmp = Files.writeString(Files.createTempFile("s3-upload", ".txt"), + "JUnit5 S3 upload test"); + + PutObjectResponse response = s3.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(KEY) + .contentType("text/plain") + .build(), + RequestBody.fromFile(tmp)); + + assertThat(response.eTag()).isNotBlank(); + Files.deleteIfExists(tmp); + } + + /** + * 2) 다운로드 + */ + @Test + @DisplayName("S3 파일 다운로드 성공") + void download() throws IOException { + Path target = Files.createTempFile("s3-download", ".txt"); + + try (ResponseInputStream obj = + s3.getObject(GetObjectRequest.builder() + .bucket(bucket) + .key(KEY) + .build())) { + obj.transferTo(Files.newOutputStream(target)); + } + + String body = Files.readString(target); + System.out.println("target = " + target); + assertThat(body).contains("JUnit5 S3 upload test"); + Files.deleteIfExists(target); + } + + /** + * 3) Presigned URL 생성 + */ + @Test + @DisplayName("Presigned URL 생성 성공") + void presignedUrl() throws URISyntaxException { + URI url = presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest( + GetObjectRequest.builder() + .bucket(bucket) + .key(KEY) + .build()) + .build()) + .url().toURI(); + + System.out.println("Presigned URL: " + url); + assertThat(url.toString()).startsWith("https://"); + } +} diff --git a/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java new file mode 100644 index 000000000..0bf4ce8b3 --- /dev/null +++ b/src/test/java/com/sprint/mission/discodeit/storage/s3/S3BinaryContentStorageTest.java @@ -0,0 +1,90 @@ +package com.sprint.mission.discodeit.storage.s3; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.Fail.fail; + +import com.sprint.mission.discodeit.dto.data.BinaryContentDto; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class S3BinaryContentStorageTest { + + private S3BinaryContentStorage storage; + private UUID testId; + private byte[] testData; + + @BeforeAll + void setUp() throws IOException { + Properties env = new Properties(); + env.load(Files.newBufferedReader(Path.of(".env"))); + + storage = new S3BinaryContentStorage( + env.getProperty("AWS_S3_ACCESS_KEY"), + env.getProperty("AWS_S3_SECRET_KEY"), + env.getProperty("AWS_S3_REGION"), + env.getProperty("AWS_S3_BUCKET") + ); + + testId = UUID.randomUUID(); + testData = "S3BinaryContentStorage 테스트 데이터".getBytes(); + } + + @Test + @DisplayName("파일 업로드 성공") + void put_success() { + UUID result = storage.put(testId, testData); + System.out.println("upload file id = " + result); + assertThat(result).isEqualTo(testId); + } + + @Test + @DisplayName("파일 조회 성공") + void get_success() { + UUID testId = UUID.randomUUID(); + storage.put(testId, testData); + + try (InputStream inputStream = storage.get(testId)) { + byte[] retrievedData = inputStream.readAllBytes(); + assertThat(retrievedData).isEqualTo(testData); + + } catch (IOException e) { + fail("파일 조회 중 예외 발생: " + e.getMessage(), e); + } + } + + @Test + @DisplayName("다운로드 - PresignedUrl 리다이렉트") + void download_success() { + UUID testId = UUID.randomUUID(); + storage.put(testId, testData); + + BinaryContentDto binaryContentDto = new BinaryContentDto( + testId, + "test-file.txt", + (long) testData.length, + "text/plain" + ); + + ResponseEntity response = storage.download(binaryContentDto); + + System.out.println( + "response.getHeaders().getLocation() = " + response.getHeaders().getLocation()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.TEMPORARY_REDIRECT); + assertThat(response.getHeaders().getLocation()).isNotNull(); + + } + +} diff --git a/task-definition.json b/task-definition.json new file mode 100644 index 000000000..da72d5f91 --- /dev/null +++ b/task-definition.json @@ -0,0 +1,59 @@ +{ + "containerDefinitions": [ + { + "name": "discodeit-app", + "image": "public.ecr.aws/j1s9e0a6/discodeit:1.2-M8", + "cpu": 256, + "memory": 512, + "memoryReservation": 256, + "portMappings": [ + { + "name": "discodeit-app-80-tcp", + "containerPort": 80, + "hostPort": 80, + "protocol": "tcp", + "appProtocol": "http" + } + ], + "essential": true, + "environment": [], + "environmentFiles": [ + { + "value": "arn:aws:s3:::discodeit-binary-content-storage-ljy/discodeit.env", + "type": "s3" + } + ], + "mountPoints": [], + "volumesFrom": [], + "ulimits": [], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/discodeit-task", + "awslogs-create-group": "true", + "awslogs-region": "ap-northeast-2", + "awslogs-stream-prefix": "ecs" + }, + "secretOptions": [] + }, + "systemControls": [] + } + ], + "family": "discodeit-task", + "executionRoleArn": "arn:aws:iam::318911662229:role/ecsTaskExecutionRole", + "networkMode": "bridge", + "volumes": [], + "placementConstraints": [], + "compatibilities": [ + "EC2" + ], + "requiresCompatibilities": [ + "EC2" + ], + "cpu": "256", + "memory": "512", + "runtimePlatform": { + "cpuArchitecture": "X86_64", + "operatingSystemFamily": "LINUX" + } +} \ No newline at end of file