diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d81a01..4fec902 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: CI / CD on: push: - branches: [main] + branches: [feat/#11-get-projectid-zone] jobs: CI: diff --git a/build.gradle b/build.gradle index ed6f3ae..3431855 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,10 @@ dependencies { implementation 'com.google.cloud:google-cloud-compute:1.14.0' // Compute Engine API implementation 'com.google.cloud:google-cloud-billing' // GCP Billing API implementation 'com.google.cloud:google-cloud-monitoring' // Cloud Monitoring API + implementation "com.google.cloud:google-cloud-resourcemanager" + implementation "com.google.cloud:google-cloud-logging" + + implementation 'com.google.api:gax-httpjson:0.107.0' implementation 'com.google.auth:google-auth-library-oauth2-http' // GCP 인증 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-web' // Spring Boot 웹 서버 @@ -39,7 +43,6 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient:4.5.13' - implementation 'com.google.cloud:google-cloud-logging:3.21.3' implementation 'com.google.code.gson:gson' // JSON 처리 diff --git a/src/main/java/com/gcp/domain/discord/repository/DiscordUserRepository.java b/src/main/java/com/gcp/domain/discord/repository/DiscordUserRepository.java index a4135c2..fa35fb1 100644 --- a/src/main/java/com/gcp/domain/discord/repository/DiscordUserRepository.java +++ b/src/main/java/com/gcp/domain/discord/repository/DiscordUserRepository.java @@ -16,6 +16,11 @@ public interface DiscordUserRepository extends JpaRepository Optional findByUserIdAndGuildId(@Param("userId") String userId, @Param("guildId") String guildId); + + boolean existsByUserIdAndGuildId(String userId, String guildId); + + + @Query("SELECT u FROM DiscordUser u WHERE u.googleAccessToken = :googleAccessToken") Optional findByGoogleAccessToken(@Param("googleAccessToken") String googleAccessToken); diff --git a/src/main/java/com/gcp/domain/discord/service/DiscordUserService.java b/src/main/java/com/gcp/domain/discord/service/DiscordUserService.java index 5018f52..46c3af0 100644 --- a/src/main/java/com/gcp/domain/discord/service/DiscordUserService.java +++ b/src/main/java/com/gcp/domain/discord/service/DiscordUserService.java @@ -10,9 +10,9 @@ public class DiscordUserService { private final DiscordUserRepository discordUserRepository; - // + public boolean insertDiscordUser(String userId, String userName, String guildId, String guildName){ - if (discordUserRepository.findByUserIdAndGuildId(userId, guildId).isEmpty()) { + if (discordUserRepository.existsByUserIdAndGuildId(userId, guildId)) { DiscordUser discordUser = new DiscordUser(userId, userName, guildId, guildName); discordUserRepository.save(discordUser); return true; diff --git a/src/main/java/com/gcp/domain/discord/service/GcpBotService.java b/src/main/java/com/gcp/domain/discord/service/GcpBotService.java index 79963d1..f157f0f 100644 --- a/src/main/java/com/gcp/domain/discord/service/GcpBotService.java +++ b/src/main/java/com/gcp/domain/discord/service/GcpBotService.java @@ -1,5 +1,7 @@ package com.gcp.domain.discord.service; +import com.gcp.domain.gcp.dto.ProjectZoneDto; +import com.gcp.domain.gcp.service.GcpProjectCommandService; import com.gcp.domain.gcp.service.GcpService; import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.entities.Guild; @@ -15,12 +17,14 @@ import java.util.Base64; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class GcpBotService extends ListenerAdapter { private final GcpService gcpService; private final DiscordUserService discordUserService; + private final GcpProjectCommandService gcpProjectCommandService; @Override public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { @@ -46,7 +50,21 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { : userName + "님은 " + guildName + "에 이미 등록되어 있습니다."; event.reply(responseMsg).queue(); } - case "register" -> { + + case "project-list" -> { + List userProjectIds = gcpService.getProjectIds(userId, guildId); + if (userProjectIds.isEmpty()) { + event.reply("📭 참여 중인 프로젝트가 없습니다.").queue(); + } else { + String message = "📦 **참여 중인 프로젝트 목록**\n" + + userProjectIds.stream() + .map(id -> "• " + id) + .collect(Collectors.joining("\n")); + event.reply(message).queue(); + } + } + + case "login" -> { String userProfile = Optional.ofNullable(author.getAvatarUrl()) .orElse(author.getDefaultAvatarUrl()); @@ -72,6 +90,40 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { event.reply("👇 아래 링크를 클릭해서 Google 계정을 연결해주세요:\n" + redirectUri).queue(); } + + case "project-register" -> { + try{ + String projectId = getRequiredOption(event, "project_id"); + gcpProjectCommandService.insertNewGcpProject(userId, guildId, projectId); + event.reply("프로젝트가 등록되었습니다.").queue(); + } catch (RuntimeException e){ + event.reply(e.getMessage()).queue(); + } + } + + case "zone-list" -> { + try { + List result = gcpService.getActiveInstanceZones(userId, guildId); + + StringBuilder message = new StringBuilder("📦 **프로젝트별 인스턴스 활성 ZONE 목록**\n\n"); + for (ProjectZoneDto dto : result) { + message.append("🔹 **") + .append(dto.projectId()) + .append("**\n"); + + for (String zone : dto.zoneList()) { + message.append("↳ ").append(zone).append("\n"); + } + + message.append("\n"); + } + + event.reply(message.toString()).queue(); + } catch (Exception e) { + event.reply("❌ 오류 발생: " + e.getMessage()).queue(); + } + } + case "start" -> { String vmName = getRequiredOption(event, "vm_name"); event.reply(gcpService.startVM(userId, guildId, vmName)).queue(); diff --git a/src/main/java/com/gcp/domain/gcp/dto/ProjectZoneDto.java b/src/main/java/com/gcp/domain/gcp/dto/ProjectZoneDto.java new file mode 100644 index 0000000..44cb9a7 --- /dev/null +++ b/src/main/java/com/gcp/domain/gcp/dto/ProjectZoneDto.java @@ -0,0 +1,11 @@ +package com.gcp.domain.gcp.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record ProjectZoneDto( + String projectId, List zoneList +) { +} diff --git a/src/main/java/com/gcp/domain/gcp/entity/GcpProject.java b/src/main/java/com/gcp/domain/gcp/entity/GcpProject.java index 25cfdbd..09810b1 100644 --- a/src/main/java/com/gcp/domain/gcp/entity/GcpProject.java +++ b/src/main/java/com/gcp/domain/gcp/entity/GcpProject.java @@ -2,16 +2,14 @@ import com.gcp.domain.discord.entity.DiscordUser; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; @Entity @Table(name = "gcp_project") @Getter @Setter +@Builder @NoArgsConstructor @AllArgsConstructor public class GcpProject { @@ -19,12 +17,15 @@ public class GcpProject { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String projectId; - private String zone; - - @Column(columnDefinition = "TEXT") - private String credentialsJson; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private DiscordUser discordUser; + + public static GcpProject create(String projectId, DiscordUser discordUser) { + return GcpProject.builder() + .projectId(projectId) + .discordUser(discordUser) + .build(); + } } diff --git a/src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java b/src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java index 71b37e4..e4b7000 100644 --- a/src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java +++ b/src/main/java/com/gcp/domain/gcp/repository/GcpProjectRepository.java @@ -4,8 +4,10 @@ import com.gcp.domain.gcp.entity.GcpProject; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -13,4 +15,9 @@ public interface GcpProjectRepository extends JpaRepository { @Query("SELECT p FROM GcpProject p WHERE p.discordUser = :discordUser") Optional findByDiscordUser(DiscordUser discordUser); + + @Query("SELECT p.projectId FROM GcpProject p WHERE p.discordUser = :discordUser") + Optional> findAllProjectIdsByDiscordUser(DiscordUser discordUser); + + boolean existsByProjectIdAndDiscordUser(String projectId, DiscordUser discordUser); } diff --git a/src/main/java/com/gcp/domain/gcp/service/GcpProjectCommandService.java b/src/main/java/com/gcp/domain/gcp/service/GcpProjectCommandService.java new file mode 100644 index 0000000..f472d03 --- /dev/null +++ b/src/main/java/com/gcp/domain/gcp/service/GcpProjectCommandService.java @@ -0,0 +1,5 @@ +package com.gcp.domain.gcp.service; + +public interface GcpProjectCommandService { + void insertNewGcpProject(String userId, String guildId, String projectId); +} diff --git a/src/main/java/com/gcp/domain/gcp/service/GcpProjectCommandServiceImpl.java b/src/main/java/com/gcp/domain/gcp/service/GcpProjectCommandServiceImpl.java new file mode 100644 index 0000000..c60ede9 --- /dev/null +++ b/src/main/java/com/gcp/domain/gcp/service/GcpProjectCommandServiceImpl.java @@ -0,0 +1,30 @@ +package com.gcp.domain.gcp.service; + +import com.gcp.domain.discord.entity.DiscordUser; +import com.gcp.domain.discord.repository.DiscordUserRepository; +import com.gcp.domain.gcp.entity.GcpProject; +import com.gcp.domain.gcp.repository.GcpProjectRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GcpProjectCommandServiceImpl implements GcpProjectCommandService{ + + private final GcpProjectRepository gcpProjectRepository; + private final DiscordUserRepository discordUserRepository; + + @Override + public void insertNewGcpProject(String userId, String guildId, String projectId) { + DiscordUser discordUser = discordUserRepository.findByUserIdAndGuildId(userId, guildId).orElseThrow( + () -> new RuntimeException("해당 사용자를 찾을 수 없습니다. /gcp init 명령어를 먼저 실행해주세요.") + ); + + if(!gcpProjectRepository.existsByProjectIdAndDiscordUser(projectId, discordUser)){ + GcpProject gcpProject = GcpProject.create(projectId, discordUser); + gcpProjectRepository.save(gcpProject); + } else{ + throw new RuntimeException("이미 등록된 프로젝트 입니다."); + } + } +} diff --git a/src/main/java/com/gcp/domain/gcp/service/GcpService.java b/src/main/java/com/gcp/domain/gcp/service/GcpService.java index 6581f7b..c6bf046 100644 --- a/src/main/java/com/gcp/domain/gcp/service/GcpService.java +++ b/src/main/java/com/gcp/domain/gcp/service/GcpService.java @@ -2,10 +2,17 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.gcp.domain.discord.entity.DiscordUser; import com.gcp.domain.discord.repository.DiscordUserRepository; +import com.gcp.domain.gcp.dto.ProjectZoneDto; +import com.gcp.domain.gcp.repository.GcpProjectRepository; +import com.google.cloud.compute.v1.Project; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.units.qual.A; +import org.json.JSONArray; +import org.json.JSONObject; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -20,12 +27,11 @@ public class GcpService { private final RestTemplate restTemplate = new RestTemplate(); private final DiscordUserRepository discordUserRepository; + private final GcpProjectRepository gcpProjectRepository; private static final String ZONE = "us-central1-f"; private static final String PROJECT_ID = "sincere-elixir-464606-j1"; - - public String startVM(String userId, String guildId, String vmName) { try { String url = String.format( @@ -194,6 +200,71 @@ public List> getVmList(String userId, String guildId) { return parseVmResponse(response.getBody()); } + public List getProjectIds(String userId, String guildId) { + String url = "https://cloudresourcemanager.googleapis.com/v1/projects"; + String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow(); + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(null, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + + JSONObject json = new JSONObject(response.getBody()); + JSONArray projects = json.getJSONArray("projects"); + + List projectIds = new ArrayList<>(); + for (int i = 0; i < projects.length(); i++) { + projectIds.add(projects.getJSONObject(i).getString("projectId")); + } + return projectIds; + } + + public List getActiveInstanceZones(String userId, String guildId) { + String accessToken = discordUserRepository.findAccessTokenByUserIdAndGuildId(userId, guildId).orElseThrow(); + DiscordUser discordUser = discordUserRepository.findByUserIdAndGuildId(userId, guildId).orElseThrow(); + List projectIds = gcpProjectRepository.findAllProjectIdsByDiscordUser(discordUser).orElseThrow(); + + List activeZones = new ArrayList<>(); + + for (String projectId : projectIds) { + try { + String url = String.format("https://compute.googleapis.com/compute/v1/projects/%s/aggregated/instances", projectId); + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + JSONObject json = new JSONObject(response.getBody()); + JSONObject items = json.getJSONObject("items"); + + List zoneNames = new ArrayList<>(); + + for (String key : items.keySet()) { + JSONObject zoneInfo = items.getJSONObject(key); + if (zoneInfo.has("instances") && key.startsWith("zones/")) { + String zoneName = key.substring("zones/".length()); + zoneNames.add(zoneName); + } + } + + ProjectZoneDto dto = ProjectZoneDto.builder() + .projectId(projectId) + .zoneList(zoneNames) + .build(); + activeZones.add(dto); + + } catch (Exception e) { + log.warn("프로젝트 Zone 조회 실패 {}: {}", projectId, e.getMessage()); + } + } + + return activeZones; + } + + private static List> parseVmResponse(String json) throws IOException { List> vmList = new ArrayList<>(); ObjectMapper objectMapper = new ObjectMapper(); diff --git a/src/main/java/com/gcp/domain/oauth2/handler/OAuth2AuthenticationSuccessHandler.java b/src/main/java/com/gcp/domain/oauth2/handler/OAuth2AuthenticationSuccessHandler.java index 3e41be8..eef2360 100644 --- a/src/main/java/com/gcp/domain/oauth2/handler/OAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/com/gcp/domain/oauth2/handler/OAuth2AuthenticationSuccessHandler.java @@ -78,8 +78,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo } DiscordUser discordUser = discordUserRepository.findByUserIdAndGuildId(userId, guildId) - .orElseThrow(() -> new IllegalStateException( - String.format("DiscordUser를 찾을 수 없습니다: userId=%s, guildId=%s", userId, guildId))); + .orElseThrow(() -> new IllegalStateException( + String.format("DiscordUser를 찾을 수 없습니다: userId=%s, guildId=%s", userId, guildId))); String googleAccessToken = client.getAccessToken().getTokenValue(); String googleRefreshToken = client.getRefreshToken().getTokenValue(); diff --git a/src/main/java/com/gcp/global/config/DiscordBotConfig.java b/src/main/java/com/gcp/global/config/DiscordBotConfig.java index cc659f6..1e44a68 100644 --- a/src/main/java/com/gcp/global/config/DiscordBotConfig.java +++ b/src/main/java/com/gcp/global/config/DiscordBotConfig.java @@ -39,7 +39,11 @@ public JDA jda() throws Exception { Commands.slash("gcp", "GCP 관련 명령어") .addSubcommands( new SubcommandData("init", "디스코드 유저 등록"), - new SubcommandData("register", "Google 계정 연동"), + new SubcommandData("login", "Google 계정 연동"), + new SubcommandData("project-list", "소속 프로젝트 ID 목록 조회"), + new SubcommandData("project-register", "프로젝트 ID를 서버에 등록") + .addOption(OptionType.STRING, "project_id", "등록하고자 하는 프로젝트 ID", true), + new SubcommandData("zone-list", "프로젝트 내 VM Zone 목록 조회"), new SubcommandData("start", "VM 시작") .addOption(OptionType.STRING, "vm_name", "시작할 VM 이름", true), new SubcommandData("stop", "VM 정지")