diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index bb50f5e..650934b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -25,7 +25,7 @@ jobs: username: ${{ secrets.SSH_USERNAME }} key: ${{ secrets.SSH_KEY }} port: 22 - source: "docker-compose.yml,.env" + source: "docker/,.env,nginx/,deploy.sh" target: "/home/ubuntu/growin/" # EC2에서 도커 컨테이너 재배포 @@ -43,10 +43,8 @@ jobs: export GHCR_USERNAME=${{ github.actor }} echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin - sudo docker-compose pull - - sudo docker-compose down || true - - sudo docker-compose up -d - - sudo docker image prune -a -f + # 공통 컨테이너 실행 + sudo docker compose -f docker/docker-compose.common.yml up -d + + # 배포 로직 실행 + ./deploy.sh \ No newline at end of file diff --git a/.github/workflows/pr_upload_notify.yml b/.github/workflows/pr_upload_notify.yml new file mode 100644 index 0000000..2cd1d6b --- /dev/null +++ b/.github/workflows/pr_upload_notify.yml @@ -0,0 +1,49 @@ +name: Notify Slack on Pull Request Upload + +on: + pull_request: + types: [opened] + +jobs: + slack_notification: + runs-on: ubuntu-latest + steps: + - name: Send Slack notification + uses: slackapi/slack-github-action@v1.27.0 + with: + payload: | + { + "text": ":bell: *New Pull Request!*", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":sparkles: *${{ github.actor }}* opened a PR" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Title:*\n${{ github.event.pull_request.title }}" + }, + { + "type": "mrkdwn", + "text": "*Base Branch:*\n${{ github.base_ref }}" + }, + { + "type": "mrkdwn", + "text": "*From Branch:*\n${{ github.head_ref }}" + }, + { + "type": "mrkdwn", + "text": "*Link:*\n<${{ github.event.pull_request.html_url }}|View PR>" + } + ] + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..c8d1928 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +APP_NAME=growin-api +BLUE_PORT=8080 +GREEN_PORT=8081 +NGINX_CONF=/home/ubuntu/growin/nginx/default.conf + + +echo "deploy start" + +if ! docker ps --format '{{.Names}}' | grep -q "${APP_NAME}-blue" && \ + ! docker ps --format '{{.Names}}' | grep -q "${APP_NAME}-green"; then + echo "First deployment detected — starting blue container..." + docker compose -f docker/docker-compose.blue.yml up -d + exit 0 +fi + + +if docker ps --format '{{.Names}}' | grep -q "${APP_NAME}-blue"; then + CURRENT="blue" + NEXT="green" + CURRENT_PORT=$BLUE_PORT + NEXT_PORT=$GREEN_PORT +else + CURRENT="green" + NEXT="blue" + CURRENT_PORT=$GREEN_PORT + NEXT_PORT=$BLUE_PORT +fi + + +ehco "Current Container : $CURRENT" +echo "Next Container : $NEXT" + +echo "deploy $NEXT Container" +docker compose -f docker/docker-compose.${NEXT}.yml up -d + + +echo "running health check" +success=false +for i in {1..20}; do + sleep 3 + if curl -fs "http://localhost:${NEXT_PORT}/test/health" | grep -q "UP"; then + ehco "Health Check Passed" + success=true + break + fi + echo "Waiting for Service to be UP ... (${i}/10)" +done + + +# 실행 실패 시 -> 롤백 진행 후 종료 +if [ "$success" = false ]; then + echo "Health check failed! Rolling back..." + docker compose -f docker/docker-compose.${NEXT}.yml down + exit 1 +fi + + +# Reload Nginx +echo "if success, switch nginx conf and stop old container" + sudo sed -i "s/${APP_NAME}-${CURRENT}/${APP_NAME}-${NEXT}/" $NGINX_CONF + sudo sed -i "s/${CURRENT_PORT}/${NEXT_PORT}/" $NGINX_CONF + sudo docker exec nginx nginx -s reload + +# Stop old container +echo "==> Stopping old container ${APP_NAME}_${CURRENT}" +docker stop ${APP_NAME}-${CURRENT} || true +docker rm ${APP_NAME}-${CURRENT} || true + +echo "Cleaning unused images" +docker image prune -f >/dev/null 2>&1 + +echo "==============================" +echo "DEPLOYMENT SUCCESSFUL" +echo "Active container: ${NEXT}" +echo "==============================" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 50ec9e7..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,8 +0,0 @@ -services: - growin-api: - image: ghcr.io/growin-2025/growin-server:latest - container_name: growin-api - ports: - - "8080:8080" - env_file: - - .env diff --git a/docker/docker-compose.blue.yml b/docker/docker-compose.blue.yml new file mode 100644 index 0000000..4203a6d --- /dev/null +++ b/docker/docker-compose.blue.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + growin-api-blue: + image: ghcr.io/growin-2025/growin-server:latest + container_name: growin-api-blue + ports: + - "8080:8080" + env_file: + - .env + networks: + - app-network + +networks: + app-network: + external: true \ No newline at end of file diff --git a/docker/docker-compose.common.yml b/docker/docker-compose.common.yml new file mode 100644 index 0000000..4f82811 --- /dev/null +++ b/docker/docker-compose.common.yml @@ -0,0 +1,31 @@ +version: '3.8' + +services: + nginx: + container_name: nginx + image: nginx:latest + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx:/etc/nginx/conf.d + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + restart: always + networks: + - growin-network + + certbot: + container_name: certbot + image: certbot/certbot + volumes: + - ./data/certbot/conf:/etc/letsencrypt + - ./data/certbot/www:/var/www/certbot + entrypoint: /bin/sh -c "trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;" + networks: + - growin-network + +networks: + app-network: + name: growin-network + driver: bridge \ No newline at end of file diff --git a/docker/docker-compose.green.yml b/docker/docker-compose.green.yml new file mode 100644 index 0000000..6d597bf --- /dev/null +++ b/docker/docker-compose.green.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + growin-api-green: + image: ghcr.io/growin-2025/growin-server:latest + container_name: growin-api-green + ports: + - "8081:8080" + env_file: + - .env + networks: + - app-network + +networks: + app-network: + external: true \ No newline at end of file diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..4414807 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,32 @@ +# HTTP → HTTPS 리다이렉트 +server { + listen 80; + server_name growinserver.shop; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + allow all; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS 설정 +server { + listen 443 ssl; + server_name growinserver.shop; + + ssl_certificate /etc/letsencrypt/live/growinserver.shop/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/growinserver.shop/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://growin-api-blue:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/src/main/java/ita/growin/domain/task/controller/TaskController.java b/src/main/java/ita/growin/domain/task/controller/TaskController.java new file mode 100644 index 0000000..a5db466 --- /dev/null +++ b/src/main/java/ita/growin/domain/task/controller/TaskController.java @@ -0,0 +1,82 @@ +//package ita.growin.domain.task.controller; +// +//import io.swagger.v3.oas.annotations.Operation; +//import io.swagger.v3.oas.annotations.tags.Tag; +//import ita.growin.domain.task.dto.CreateTaskRequestDto; +//import ita.growin.domain.task.dto.UpdateTaskRequestDto; +//import ita.growin.domain.task.service.TaskService; +//import ita.growin.global.response.APIResponse; +//import org.springframework.data.domain.Pageable; +//import org.springframework.data.domain.Slice; +//import org.springframework.data.web.PageableDefault; +//import org.springframework.lang.Nullable; +//import org.springframework.web.bind.annotation.*; +// +//@RestController +//@RequestMapping("/api/v1/tasks") +//@Tag(name = "할 일", description = "할 일 관련 API") +//public class TaskController { +// +// private final TaskService taskService; +// +// public TaskController(TaskService taskService) { +// this.taskService = taskService; +// } +// +// @PostMapping +// @Operation(summary = "할 일 생성 API") +// public APIResponse createTask( +// @RequestParam("eventId") @Nullable Long eventId, +// @RequestBody CreateTaskRequestDto req) { +// taskService.createTask(eventId, req); +// return APIResponse.success(); +// } +// +// @PatchMapping("/{taskId}") +// @Operation(summary = "할 일 수정 API") +// public APIResponse updateTask( +// @PathVariable Long taskId, +// @RequestBody UpdateTaskRequestDto req) { +// taskService.updateTask(taskId, req); +// return APIResponse.success(); +// } +// +// @DeleteMapping("/{taskId}") +// @Operation(summary = "할 일 삭제 API") +// public APIResponse deleteTask( +// @RequestParam("eventId") @Nullable Long eventId, +// @PathVariable Long taskId) { +// taskService.deleteTask(eventId, taskId); +// return APIResponse.success(); +// } +// +// @GetMapping("/{taskId}") +// @Operation(summary = "할 일 단건 조회") +// public APIResponse getTask(@PathVariable Long taskId) { +// return APIResponse.success(taskService.getTask(taskId)); +// } +// +// @GetMapping("/event/{eventId}/tasks") +// @Operation(summary = "일정 내 할 일 조회") +// public APIResponse> getTasks( +// @PathVariable Long eventId, +// @PageableDefault(size = 20) Pageable pageable) { +// return APIResponse.success(taskService.getTasks(eventId, pageable)); +// } +// +// @GetMapping("/tasks/today") +// @Operation(summary = "오늘 할 일 조회") +// public APIResponse getTodayTasks( +// @PageableDefault(size = 20) Pageable pageable +// ) { +// return APIResponse.success(taskService.getTodayTasks(pageable)); +// } +// +// @GetMapping("/tasks/someday") +// @Operation(summary = "언젠가 할 일 조회") +// public APIResponse getSomedayTasks( +// @PageableDefault(size = 20) Pageable pageable +// ) { +// return APIResponse.success(taskService.getSomedayTasks(pageable)); +// } +//} diff --git a/src/main/java/ita/growin/global/config/SecurityConfig.java b/src/main/java/ita/growin/global/config/SecurityConfig.java new file mode 100644 index 0000000..61bb16b --- /dev/null +++ b/src/main/java/ita/growin/global/config/SecurityConfig.java @@ -0,0 +1,20 @@ +package ita.growin.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable); + return http.build(); + } +} diff --git a/src/main/java/ita/growin/global/config/SwaggerConfig.java b/src/main/java/ita/growin/global/config/SwaggerConfig.java new file mode 100644 index 0000000..db83bed --- /dev/null +++ b/src/main/java/ita/growin/global/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package ita.growin.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList("BearerAuth")) + .components( + new Components() + .addSecuritySchemes( + "BearerAuth", + new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER))) + .info( + new Info() + .title("Growin Swagger Page") + .license( + new License() + .name("growin API 명세서") + .url("http://localhost:8080"))); + } +} diff --git a/src/main/java/ita/growin/global/health/controller/HealthCheckController.java b/src/main/java/ita/growin/global/health/controller/HealthCheckController.java index 481870e..62852f8 100644 --- a/src/main/java/ita/growin/global/health/controller/HealthCheckController.java +++ b/src/main/java/ita/growin/global/health/controller/HealthCheckController.java @@ -3,12 +3,18 @@ import ita.growin.global.exception.BusinessException; import ita.growin.global.exception.errorcode.BusinessErrorCode; import ita.growin.global.response.APIResponse; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class HealthCheckController { + @GetMapping("/test/health") + public ResponseEntity health() { + return ResponseEntity.ok("UP"); + } + @GetMapping("/health") public APIResponse test() { return APIResponse.success("test"); diff --git a/src/main/java/ita/growin/global/response/APIResponse.java b/src/main/java/ita/growin/global/response/APIResponse.java index 2635f34..3a78272 100644 --- a/src/main/java/ita/growin/global/response/APIResponse.java +++ b/src/main/java/ita/growin/global/response/APIResponse.java @@ -16,7 +16,13 @@ public record APIResponse( private static final String SUCCESS_MESSAGE = "요청이 성공적으로 처리되었습니다."; - // 성공응답 + // 성공응답 (data X) + public static APIResponse success() { + return new APIResponse<>( + HttpStatus.OK.value(), null, SUCCESS_MESSAGE, LocalDateTime.now(), null); + } + + // 성공응답 (data 존재) public static APIResponse success(T data) { return new APIResponse<>( HttpStatus.OK.value(), null, SUCCESS_MESSAGE, LocalDateTime.now(), data);