diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml new file mode 100644 index 0000000..b49c6e5 --- /dev/null +++ b/.github/workflows/cd-prod.yml @@ -0,0 +1,145 @@ +name: CD-PROD + +on: + workflow_run: + workflows: [ "CI-PROD" ] + types: [ completed ] + workflow_dispatch: + +concurrency: + group: cd-prod + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +env: + AWS_REGION: ${{ vars.AWS_REGION }} + ECR_REGISTRY: ${{ vars.ECR_REGISTRY }} + ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }} + ECS_CLUSTER: ${{ vars.ECS_CLUSTER }} + ECS_SERVICE: ${{ vars.ECS_SERVICE }} + TASK_FAMILY: ${{ vars.TASK_FAMILY }} + CONTAINER_NAME: ${{ vars.CONTAINER_NAME }} + +jobs: + deploy: + if: > + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + ) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Resolve image tag + id: tag + run: | + if [ "${{ github.event_name }}" = "workflow_run" ]; then + IMAGE_TAG="${{ github.event.workflow_run.head_sha }}" + else + IMAGE_TAG="${{ github.sha }}" + fi + IMAGE_TAG="${IMAGE_TAG:0:7}" + echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT" + + - name: Verify image exists in ECR + run: | + aws ecr describe-images \ + --repository-name "${ECR_REPOSITORY}" \ + --image-ids imageTag="${{ steps.tag.outputs.image_tag }}" \ + > /dev/null + + - name: Get current task definition + run: | + aws ecs describe-task-definition \ + --task-definition "${TASK_FAMILY}" \ + --query 'taskDefinition' > taskdef.json + + - name: Render new task definition + env: + IMAGE_URI: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.tag.outputs.image_tag }} + run: | + jq \ + --arg CN "${CONTAINER_NAME}" \ + --arg IMG "${IMAGE_URI}" \ + --arg SPRING_PROFILES_ACTIVE "prod" \ + --arg SERVER_PORT "${{ vars.SERVER_PORT }}" \ + --arg POSTGRES_HOST "${{ vars.POSTGRES_HOST }}" \ + --arg POSTGRES_PORT "${{ vars.POSTGRES_PORT }}" \ + --arg POSTGRES_DB "${{ vars.POSTGRES_DB }}" \ + --arg POSTGRES_USER "${{ vars.POSTGRES_USER }}" \ + --arg POSTGRES_PASSWORD "${{ secrets.POSTGRES_PASSWORD }}" \ + --arg REDIS_HOST "${{ vars.REDIS_HOST }}" \ + --arg REDIS_PORT "${{ vars.REDIS_PORT }}" \ + --arg KAFKA_BROKERS "${{ vars.KAFKA_BROKERS }}" \ + --arg EUREKA_ENABLED "${{ vars.EUREKA_ENABLED }}" \ + --arg EUREKA_HOST "${{ vars.EUREKA_HOST }}" \ + --arg EUREKA_PORT "${{ vars.EUREKA_PORT }}" \ + --arg JWT_SECRET "${{ secrets.JWT_SECRET }}" \ + --arg INTERNAL_AUTH_SECRET "${{ secrets.INTERNAL_AUTH_SECRET }}" \ + ' + .containerDefinitions |= map( + if .name == $CN then + .image = $IMG + | .environment = [ + {"name":"SPRING_PROFILES_ACTIVE","value":$SPRING_PROFILES_ACTIVE}, + {"name":"SERVER_PORT","value":$SERVER_PORT}, + {"name":"POSTGRES_HOST","value":$POSTGRES_HOST}, + {"name":"POSTGRES_PORT","value":$POSTGRES_PORT}, + {"name":"POSTGRES_DB","value":$POSTGRES_DB}, + {"name":"POSTGRES_USER","value":$POSTGRES_USER}, + {"name":"POSTGRES_PASSWORD","value":$POSTGRES_PASSWORD}, + {"name":"REDIS_HOST","value":$REDIS_HOST}, + {"name":"REDIS_PORT","value":$REDIS_PORT}, + {"name":"KAFKA_BROKERS","value":$KAFKA_BROKERS}, + {"name":"EUREKA_ENABLED","value":$EUREKA_ENABLED}, + {"name":"EUREKA_HOST","value":$EUREKA_HOST}, + {"name":"EUREKA_PORT","value":$EUREKA_PORT}, + {"name":"JWT_SECRET","value":$JWT_SECRET}, + {"name":"INTERNAL_AUTH_SECRET","value":$INTERNAL_AUTH_SECRET}, + {"name":"TZ","value":"Asia/Seoul"} + ] + else . + end + ) + | del( + .taskDefinitionArn, .revision, .status, + .requiresAttributes, .compatibilities, + .registeredAt, .registeredBy + ) + ' taskdef.json > taskdef.new.json + + - name: Register new task definition revision + id: register + run: | + ARN=$(aws ecs register-task-definition \ + --cli-input-json file://taskdef.new.json \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text) + echo "task_def_arn=${ARN}" >> "$GITHUB_OUTPUT" + + - name: Deploy to ECS service + run: | + aws ecs update-service \ + --cluster "${ECS_CLUSTER}" \ + --service "${ECS_SERVICE}" \ + --task-definition "${{ steps.register.outputs.task_def_arn }}" \ + --force-new-deployment + + - name: Wait for stable + run: | + aws ecs wait services-stable \ + --cluster "${ECS_CLUSTER}" \ + --services "${ECS_SERVICE}" diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml new file mode 100644 index 0000000..c220937 --- /dev/null +++ b/.github/workflows/ci-prod.yml @@ -0,0 +1,57 @@ +name: CI-PROD + +on: + pull_request: + branches: [ main, dev ] + push: + branches: [ main, dev ] + +permissions: + contents: read # 내 코드를 읽을 수 있게(코드이동) 접근 권한을 준다. + id-token: write # OIDC 토큰을 발급할 수 있게 한다. + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout # 내 코드 이동 깃허브 컴퓨터로 + uses: actions/checkout@v4 + + - name: Set up JDK 17 # jdk 환경 설치 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Run tests # 테스트 실행 + run: ./gradlew test --no-daemon + + - name: Generate image tag + run: echo "IMAGE_TAG=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_ENV + + - name: Configure AWS credentials (OIDC) # OIDC 기반으로 역할 자격증명 + if: github.event_name == 'push' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Login to Amazon ECR # 위에서 자격증명하고 ecr 로그인 + if: github.event_name == 'push' + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build Docker image # 이미지 빌드해서 + run: | + docker build --build-arg SERVER_PORT=${{ vars.SERVER_PORT }} -t ${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} . + + - name: Push Docker image to ECR # 이미지 ecr로 업로드 + if: github.event_name == 'push' + run: | + docker push ${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} diff --git a/.gitignore b/.gitignore index 479b760..2bd5fad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ HELP.md .gradle build/ -!gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ @@ -62,6 +61,7 @@ Temporary Items *.ctxt .mtj.tmp/ *.jar +!gradle/wrapper/gradle-wrapper.jar *.war *.nar *.ear @@ -105,4 +105,4 @@ $RECYCLE.BIN/ ### Docker ### *.tar -docker-compose.override.yml \ No newline at end of file +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile index 271db62..7b6892e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,20 @@ -FROM eclipse-temurin:17-jre +FROM eclipse-temurin:17-jdk AS builder WORKDIR /app -# 1. non-root 전용 유저 및 그룹 생성 (appuser) -RUN groupadd -r appuser && useradd -r -g appuser appuser +COPY gradlew . +COPY gradle gradle +COPY build.gradle settings.gradle ./ +COPY src src + +RUN chmod +x ./gradlew +RUN ./gradlew bootJar -x test -x asciidoctor --no-daemon -# 2. 호스트(로컬 PC)에서 이미 빌드된 jar 파일을 복사 (파일 소유권 자동 지정) -COPY --chown=appuser:appuser build/libs/*-SNAPSHOT.jar app.jar +FROM eclipse-temurin:17-jre +WORKDIR /app -# 3. 컨테이너 실행 권한을 appuser로 전환 -USER appuser +COPY --from=builder /app/build/libs/*.jar app.jar -EXPOSE 19900 +ARG SERVER_PORT=19900 +EXPOSE ${SERVER_PORT} ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/build.gradle b/build.gradle index c9927d5..5dd906b 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,10 @@ dependencies { testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.springframework.security:spring-security-test' + + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + + testImplementation 'org.testcontainers:testcontainers' } dependencyManagement { @@ -90,3 +94,8 @@ bootJar { dependsOn asciidoctor from("${asciidoctor.outputDir}") { into 'static/docs' } } + +// plain jar 생성 방지 +jar { + enabled = false +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src/main/java/com/michelet/inventory/infrastructure/config/SecurityConfig.java b/src/main/java/com/michelet/inventory/infrastructure/config/SecurityConfig.java index 8a31a08..39f81da 100644 --- a/src/main/java/com/michelet/inventory/infrastructure/config/SecurityConfig.java +++ b/src/main/java/com/michelet/inventory/infrastructure/config/SecurityConfig.java @@ -40,6 +40,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health", "/actuator/prometheus").permitAll() .requestMatchers("/api/v1/products/**", "/api/v1/admin/products/**").permitAll() .requestMatchers("/internal/**").permitAll() .anyRequest().authenticated() diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..62316ee --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,59 @@ +server: + port: ${SERVER_PORT:19900} + +spring: + application: + name: inventory-service + + datasource: + url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + driver-class-name: org.postgresql.Driver + + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + + kafka: + bootstrap-servers: ${KAFKA_BROKERS} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + consumer: + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + group-id: ${spring.application.name}-group + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + default_schema: inventory_service + format_sql: true + show-sql: true + + cloud: + inetutils: + ignored-interfaces: [ "lo", "docker.*", "veth.*" ] + preferred-networks: [ "10.0" ] + use-only-site-local-interfaces: true + +eureka: + client: + enabled: ${EUREKA_ENABLED:false} + service-url: + defaultZone: http://${EUREKA_HOST}:${EUREKA_PORT}/eureka/ + instance: + prefer-ip-address: true + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + show-details: always diff --git a/src/test/java/com/michelet/inventory/application/StockSchedulerServiceTest.java b/src/test/java/com/michelet/inventory/application/StockSchedulerServiceTest.java index ae4b668..01be473 100644 --- a/src/test/java/com/michelet/inventory/application/StockSchedulerServiceTest.java +++ b/src/test/java/com/michelet/inventory/application/StockSchedulerServiceTest.java @@ -9,14 +9,38 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.core.KafkaTemplate; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest @ActiveProfiles("test") @Transactional +@Testcontainers class StockSchedulerServiceTest { + // 테스트 실행 시 가짜 미니 Redis 컨테이너 띄우기 (인프라 환경과 동일한 버전) + @Container + static GenericContainer redis = new GenericContainer<>("redis:7-alpine") + .withExposedPorts(6379); + + // 띄워진 가짜 Redis의 동적 IP와 포트를 스프링 환경변수에 주입 + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379)); + } + + // Kafka 연결 에러(로그 도배 및 실패)를 막기 위해 가짜 템플릿 주입 + @MockitoBean + private KafkaTemplate kafkaTemplate; + @Autowired private StockSchedulerService stockSchedulerService;