EDIT subdomen
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package com.jambotronGroup.jambotron.DTOs;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ContainerStatsDTO {
|
||||
private String containerId;
|
||||
private double cpuUsage; // Процент загрузки CPU
|
||||
private long memoryUsage; // Использование памяти в байтах
|
||||
private long memoryLimit; // Лимит памяти в байтах
|
||||
private double memoryPercent; // Процент использования памяти
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.jambotronGroup.jambotron.controllers;
|
||||
|
||||
import com.jambotronGroup.jambotron.console.DockerLogService;
|
||||
import com.jambotronGroup.jambotron.projects.DockerStatsService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
@@ -9,8 +12,13 @@ public class LogController {
|
||||
|
||||
private final DockerLogService dockerLogService;
|
||||
|
||||
public LogController(DockerLogService dockerLogService) {
|
||||
private static final Logger _logger = LoggerFactory.getLogger(LogController.class);
|
||||
|
||||
private final DockerStatsService dockerStatsService;
|
||||
|
||||
public LogController(DockerLogService dockerLogService, DockerStatsService dockerStatsService) {
|
||||
this.dockerLogService = dockerLogService;
|
||||
this.dockerStatsService = dockerStatsService;
|
||||
}
|
||||
|
||||
// Когда клиент шлет сообщение на /app/start-logs
|
||||
@@ -18,4 +26,10 @@ public class LogController {
|
||||
public void handleStartLogs(String containerId) {
|
||||
dockerLogService.streamLogs(containerId);
|
||||
}
|
||||
|
||||
@MessageMapping("/start-stats")
|
||||
public void handleStartStats(String containerId) {
|
||||
_logger.info("Начинаем сбор метрик для контейнера: {}", containerId);
|
||||
dockerStatsService.startStreamingStats(containerId);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.jambotronGroup.jambotron.controllers;
|
||||
|
||||
import com.jambotronGroup.jambotron.model.Project;
|
||||
import com.jambotronGroup.jambotron.model.ProjectStatus;
|
||||
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||
import com.jambotronGroup.jambotron.model.User;
|
||||
import com.jambotronGroup.jambotron.projects.ContainerStatus;
|
||||
import com.jambotronGroup.jambotron.projects.ProjectCreateRequest;
|
||||
import com.jambotronGroup.jambotron.projects.ProjectDetailResponse;
|
||||
import com.jambotronGroup.jambotron.projects.ProjectService;
|
||||
import com.jambotronGroup.jambotron.repository.ProjectConfigRepository;
|
||||
import com.jambotronGroup.jambotron.repository.UserRepository;
|
||||
import com.jambotronGroup.jambotron.security.AuthenticationFacade;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -15,7 +16,6 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -29,181 +29,141 @@ public class ProjectController {
|
||||
private static final Logger _logger = LoggerFactory.getLogger(ProjectController.class);
|
||||
|
||||
private final ProjectService projectService;
|
||||
|
||||
private final ProjectConfigRepository configRepository; // <-- Это то, что я чуть не забыла!
|
||||
private final UserRepository userRepository;
|
||||
private final AuthenticationFacade authenticationFacade;
|
||||
|
||||
//-------------------- Просмотр и Dashboard ----------------------------
|
||||
// --- Просмотр списка и дашборд ---
|
||||
|
||||
/**
|
||||
* Получение списка проектов текущего пользователя (Dashboard)[cite: 2, 3].
|
||||
*/
|
||||
@GetMapping("/user/projects")
|
||||
public ResponseEntity<List<Project>> getUserProjects() {
|
||||
try {
|
||||
User user = getCurrentUser();
|
||||
List<Project> projects = projectService.findAllByOwnerId(user.getId());
|
||||
|
||||
if (projects.isEmpty()) {
|
||||
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
return new ResponseEntity<>(projects, HttpStatus.OK);
|
||||
} catch (Exception e) {
|
||||
_logger.error("Error fetching user projects", e);
|
||||
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
User user = getCurrentUser();
|
||||
List<Project> projects = projectService.findAllByOwnerId(user.getId());
|
||||
return projects.isEmpty() ? new ResponseEntity<>(HttpStatus.NO_CONTENT) : ResponseEntity.ok(projects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Детали проекта (Project + Config). Доступно владельцу или админу.
|
||||
*/
|
||||
@GetMapping("/user/projects/{id}/details")
|
||||
public ResponseEntity<ProjectDetailResponse> getProjectDetails(@PathVariable UUID id) {
|
||||
try {
|
||||
User user = getCurrentUser();
|
||||
boolean isAdmin = checkIfAdmin(user);
|
||||
|
||||
// Метод сервиса сам проверит права владельца, если isAdmin = false
|
||||
ProjectDetailResponse details = projectService.getProjectDetailsSecure(
|
||||
id,
|
||||
user.getId(),
|
||||
isAdmin
|
||||
);
|
||||
|
||||
return new ResponseEntity<>(details, HttpStatus.OK);
|
||||
} catch (AccessDeniedException e) {
|
||||
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||
} catch (Exception e) {
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
//-------------------- Управление Жизненным Циклом (User & Admin) ----------------------------
|
||||
|
||||
/**
|
||||
* Деплой проекта (Docker Compose Up). Доступно владельцу или админу[cite: 1, 4].
|
||||
*/
|
||||
@PostMapping("/user/projects/{id}/deploy")
|
||||
public ResponseEntity<String> deployProject(@PathVariable UUID id) {
|
||||
// Получаем текущего пользователя (твой метод)
|
||||
User user = getCurrentUser();
|
||||
|
||||
// Используем метод сервиса findById, а не репозиторий напрямую!
|
||||
Project project = projectService.findById(id);
|
||||
|
||||
// Выполняем проверку безопасности (через String.valueOf, как мы выяснили)
|
||||
if (checkIfAdmin(user) ||
|
||||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
|
||||
|
||||
// ВАЖНО: В твоем сервисе метод называется deployProject(Project project)
|
||||
projectService.deployProject(project);
|
||||
return ResponseEntity.ok("Процесс созидания запущен...");
|
||||
}
|
||||
|
||||
return ResponseEntity.status(403).body("Access Denied");
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановка проекта (Docker Compose Down). Доступно владельцу или админу[cite: 1, 4].
|
||||
*/
|
||||
@PostMapping("/user/projects/{id}/stop")
|
||||
public ResponseEntity<String> stopProject(@PathVariable UUID id) {
|
||||
User user = getCurrentUser();
|
||||
Project project = projectService.findById(id);
|
||||
|
||||
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) {
|
||||
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||
|
||||
projectService.stopProject(project);
|
||||
project.setStatus(ProjectStatus.STOPPED);
|
||||
projectService.save(project);
|
||||
// Тянем конфиг из Mongo для детального отображения
|
||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(id))
|
||||
.orElseThrow(() -> new RuntimeException("Конфигурация созидания не найдена"));
|
||||
|
||||
return ResponseEntity.ok("Project stopped: " + project.getName());
|
||||
return ResponseEntity.ok(new ProjectDetailResponse(project, config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление проекта (Очистка Docker + Файлы + БД). Доступно владельцу или админу[cite: 1, 2].
|
||||
*/
|
||||
@DeleteMapping("/user/projects/{id}")
|
||||
public ResponseEntity<String> deleteProject(@PathVariable UUID id) {
|
||||
User user = getCurrentUser();
|
||||
Project project = projectService.findById(id);
|
||||
|
||||
// Безопасная проверка владельца (уже ставшая стандартом для нашего "сосуда")
|
||||
if (checkIfAdmin(user) ||
|
||||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
|
||||
|
||||
_logger.info("Начато полное удаление проекта: {}", id);
|
||||
projectService.deleteProject(id); // Вызов того самого метода из ProjectService_2.java
|
||||
return ResponseEntity.ok("Проект полностью удален из системы");
|
||||
}
|
||||
|
||||
return ResponseEntity.status(403).body("Access Denied");
|
||||
}
|
||||
|
||||
//-------------------- Администрирование PaaS ----------------------------
|
||||
|
||||
/**
|
||||
* Список всех проектов системы для глобального мониторинга[cite: 2].
|
||||
*/
|
||||
@GetMapping("/admin/projects/all")
|
||||
public ResponseEntity<List<Project>> getAllProjectsForAdmin() {
|
||||
User user = getCurrentUser();
|
||||
if (!checkIfAdmin(user)) {
|
||||
return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
List<Project> allProjects = projectService.findAllProjectsForAdmin();
|
||||
return new ResponseEntity<>(allProjects, HttpStatus.OK);
|
||||
}
|
||||
|
||||
//-------------------- Создание и Конфигурация ----------------------------
|
||||
// --- Создание и обновление ---
|
||||
|
||||
@PostMapping("/user/projects")
|
||||
public ResponseEntity<Project> create(@Valid @RequestBody ProjectCreateRequest request) {
|
||||
|
||||
User user = getCurrentUser();
|
||||
Project newProject = projectService.createProject(request.name(), request.rawComposeContent(), user);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(newProject);
|
||||
}
|
||||
|
||||
@PutMapping("/user/projects/{id}/config")
|
||||
public ResponseEntity<String> updateConfig(@PathVariable UUID id, @RequestBody String newContent) {
|
||||
public ResponseEntity<String> updateConfig(@PathVariable UUID id, @RequestBody ProjectConfig newConfig) {
|
||||
User user = getCurrentUser();
|
||||
Project project = projectService.findById(id);
|
||||
|
||||
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) {
|
||||
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
|
||||
if (!hasAccess(user, project)) return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
|
||||
|
||||
try {
|
||||
// Передаем весь объект для валидации поддоменов
|
||||
projectService.updateProjectConfig(id, newConfig);
|
||||
return ResponseEntity.ok("Конфигурация обновлена.");
|
||||
} catch (RuntimeException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
|
||||
}
|
||||
|
||||
projectService.updateProjectConfig(id, newContent);
|
||||
return ResponseEntity.ok("Configuration updated.");
|
||||
}
|
||||
|
||||
//-------------------- Вспомогательные методы ----------------------------
|
||||
// --- Управление всем проектом ---
|
||||
|
||||
private User getCurrentUser() {
|
||||
return userRepository.findById(authenticationFacade.getUserDetails().getId())
|
||||
.orElseThrow(() -> new RuntimeException("Current user not found"));
|
||||
@PostMapping("/user/projects/{id}/deploy")
|
||||
public ResponseEntity<String> deployProject(@PathVariable UUID id) {
|
||||
User user = getCurrentUser();
|
||||
Project project = projectService.findById(id);
|
||||
|
||||
if (!hasAccess(user, project)) return ResponseEntity.status(403).body("Access Denied");
|
||||
|
||||
projectService.deployProject(project);
|
||||
return ResponseEntity.ok("Процесс созидания запущен...");
|
||||
}
|
||||
|
||||
private boolean checkIfAdmin(User user) {
|
||||
return user.getRoles() != null && (user.getRoles().equals("ADMIN") || user.getRoles().equals("ROLE_ADMIN"));
|
||||
@PostMapping("/user/projects/{id}/stop")
|
||||
public ResponseEntity<String> stopProject(@PathVariable UUID id) {
|
||||
User user = getCurrentUser();
|
||||
Project project = projectService.findById(id);
|
||||
if (!hasAccess(user, project)) return new ResponseEntity<>(HttpStatus.FORBIDDEN);
|
||||
|
||||
projectService.stopProject(project);
|
||||
return ResponseEntity.ok("Проект остановлен.");
|
||||
}
|
||||
|
||||
@DeleteMapping("/user/projects/{id}")
|
||||
public ResponseEntity<String> deleteProject(@PathVariable UUID id) {
|
||||
User user = getCurrentUser();
|
||||
Project project = projectService.findById(id);
|
||||
if (!hasAccess(user, project)) return ResponseEntity.status(403).body("Access Denied");
|
||||
|
||||
projectService.deleteProject(id);
|
||||
return ResponseEntity.ok("Проект удален.");
|
||||
}
|
||||
|
||||
// --- Управление отдельными контейнерами ---
|
||||
|
||||
@GetMapping("/user/projects/{id}/containers")
|
||||
public ResponseEntity<List<ContainerStatus>> getContainers(@PathVariable UUID id) {
|
||||
User user = getCurrentUser();
|
||||
Project project = projectService.findById(id);
|
||||
if (!hasAccess(user, project)) return ResponseEntity.status(403).build();
|
||||
|
||||
// Применяем нашу "золотую формулу" сравнения ID
|
||||
if (checkIfAdmin(user) ||
|
||||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
|
||||
return ResponseEntity.ok(projectService.getProjectContainers(id));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(projectService.getProjectContainers(id));
|
||||
}
|
||||
// Рестарт конкретного контейнера
|
||||
@PostMapping("/user/projects/{projectId}/containers/{containerId}/restart")
|
||||
public ResponseEntity<Void> restartContainer(
|
||||
@PathVariable("projectId") UUID projectId,
|
||||
@PathVariable("containerId") String containerId) {
|
||||
projectService.restartContainer(projectId, containerId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.status(403).build();
|
||||
// Остановка конкретного контейнера
|
||||
@PostMapping("/user/projects/{projectId}/containers/{containerId}/stop")
|
||||
public ResponseEntity<Void> stopContainer(
|
||||
@PathVariable("projectId") UUID projectId,
|
||||
@PathVariable("containerId") String containerId) {
|
||||
projectService.stopContainer(projectId, containerId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
// Запуск конкретного контейнера
|
||||
@PostMapping("/user/projects/{projectId}/containers/{containerId}/start")
|
||||
public ResponseEntity<Void> startContainer(
|
||||
@PathVariable("projectId") UUID projectId,
|
||||
@PathVariable("containerId") String containerId) {
|
||||
projectService.startContainer(projectId, containerId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
// --- Утилиты ---
|
||||
|
||||
private boolean hasAccess(User user, Project project) {
|
||||
return checkIfAdmin(user) || String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()));
|
||||
}
|
||||
|
||||
private User getCurrentUser() {
|
||||
return userRepository.findById(authenticationFacade.getUserDetails().getId())
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
}
|
||||
|
||||
private boolean checkIfAdmin(User user) {
|
||||
return user.getRoles() != null && user.getRoles().contains("ADMIN");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.jambotronGroup.jambotron.model;
|
||||
|
||||
public class ContainerStats {
|
||||
}
|
||||
@@ -4,23 +4,36 @@ import lombok.*;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Document(collection = "project_configs")
|
||||
@Data // Генерирует геттеры, сеттеры, toString, equals, hashCode
|
||||
@Builder // Генерирует тот самый метод .builder()
|
||||
@NoArgsConstructor // Нужен для десериализации MongoDB
|
||||
@AllArgsConstructor // Нужен для работы @Builder
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProjectConfig {
|
||||
|
||||
@Id
|
||||
private String id; // Внутренний ID Mongo
|
||||
|
||||
private String projectId; // UUID из нашей таблицы projects
|
||||
private String projectId; // UUID из таблицы projects в Postgres
|
||||
|
||||
private String rawComposeContent; // Прямо текст твоего docker-compose.yaml
|
||||
private String rawComposeContent; // Исходный текст docker-compose.yaml
|
||||
|
||||
private Map<String, String> envVariables; // Ключ-значение для переменных
|
||||
private List<String> serviceNames; // Извлеченные имена сервисов для стабильного UI
|
||||
|
||||
private Integer version = 1; // Для отслеживания правок конфига
|
||||
private Map<String, String> envVariables; // Переменные окружения
|
||||
|
||||
private Map<String, ServiceSettings> serviceConfigs; // Настройки поддоменов и портов для каждого контейнера
|
||||
|
||||
private Integer version = 1; // Версия конфига
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ServiceSettings {
|
||||
private String subdomain; // Уникальный поддомен (например, lioshik-app-api)
|
||||
private Integer port; // Внутренний порт контейнера (8080, 80 и т.д.)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.jambotronGroup.jambotron.projects;
|
||||
|
||||
import com.github.dockerjava.api.DockerClient;
|
||||
import com.github.dockerjava.api.async.ResultCallback;
|
||||
import com.github.dockerjava.api.model.CpuStatsConfig;
|
||||
import com.github.dockerjava.api.model.Statistics;
|
||||
import com.jambotronGroup.jambotron.DTOs.ContainerStatsDTO;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DockerStatsService {
|
||||
private final DockerClient dockerClient;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public DockerStatsService(DockerClient dockerClient, SimpMessagingTemplate messagingTemplate) {
|
||||
this.dockerClient = dockerClient;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void startStreamingStats(String containerId) {
|
||||
dockerClient.statsCmd(containerId).exec(new ResultCallback.Adapter<Statistics>() {
|
||||
@Override
|
||||
public void onNext(Statistics stats) {
|
||||
ContainerStatsDTO dto = calculate(containerId, stats);
|
||||
messagingTemplate.convertAndSend("/topic/stats/" + containerId, dto);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ContainerStatsDTO calculate(String id, Statistics stats) {
|
||||
ContainerStatsDTO dto = new ContainerStatsDTO();
|
||||
dto.setContainerId(id);
|
||||
|
||||
CpuStatsConfig cpu = stats.getCpuStats();
|
||||
CpuStatsConfig preCpu = stats.getPreCpuStats();
|
||||
|
||||
// ЗАЩИТА: Проверяем наличие системных данных, чтобы избежать NullPointerException
|
||||
if (cpu == null || preCpu == null ||
|
||||
cpu.getSystemCpuUsage() == null || preCpu.getSystemCpuUsage() == null) {
|
||||
dto.setCpuUsage(0.0);
|
||||
return fillMemoryStats(dto, stats); // Память обычно доступна сразу
|
||||
}
|
||||
|
||||
// 1. Считаем дельту использования процессора контейнером
|
||||
long cpuUsageDelta = cpu.getCpuUsage().getTotalUsage() - preCpu.getCpuUsage().getTotalUsage();
|
||||
|
||||
// 2. Считаем дельту системного времени
|
||||
long systemUsageDelta = cpu.getSystemCpuUsage() - preCpu.getSystemCpuUsage();
|
||||
|
||||
// 3. Определяем количество ядер
|
||||
long onlineCpus = 0;
|
||||
if (cpu.getCpuUsage().getPercpuUsage() != null) {
|
||||
onlineCpus = cpu.getCpuUsage().getPercpuUsage().size();
|
||||
}
|
||||
if (onlineCpus == 0) {
|
||||
onlineCpus = Runtime.getRuntime().availableProcessors();
|
||||
}
|
||||
|
||||
// 4. Расчет процента с защитой от деления на ноль
|
||||
if (systemUsageDelta > 0 && cpuUsageDelta > 0) {
|
||||
double cpuPercent = ((double) cpuUsageDelta / systemUsageDelta) * onlineCpus * 100.0;
|
||||
dto.setCpuUsage(cpuPercent);
|
||||
} else {
|
||||
dto.setCpuUsage(0.0);
|
||||
}
|
||||
|
||||
return fillMemoryStats(dto, stats);
|
||||
}
|
||||
|
||||
// Выносим расчет памяти для чистоты кода
|
||||
private ContainerStatsDTO fillMemoryStats(ContainerStatsDTO dto, Statistics stats) {
|
||||
if (stats.getMemoryStats() != null) {
|
||||
long usage = stats.getMemoryStats().getUsage() != null ? stats.getMemoryStats().getUsage() : 0L;
|
||||
long limit = stats.getMemoryStats().getLimit() != null ? stats.getMemoryStats().getLimit() : 0L;
|
||||
|
||||
dto.setMemoryUsage(usage);
|
||||
dto.setMemoryLimit(limit);
|
||||
|
||||
if (limit > 0) {
|
||||
dto.setMemoryPercent((double) usage / limit * 100.0);
|
||||
}
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,39 @@ package com.jambotronGroup.jambotron.projects;
|
||||
|
||||
import com.jambotronGroup.jambotron.model.Project;
|
||||
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
public record ProjectDetailResponse(
|
||||
Project project,
|
||||
ProjectConfig config
|
||||
) {}
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProjectDetailResponse {
|
||||
private Project project;
|
||||
private ConfigDTO config;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ConfigDTO {
|
||||
private String projectId;
|
||||
private String rawComposeContent;
|
||||
private List<String> serviceNames; // Список сервисов для UI
|
||||
private Map<String, ProjectConfig.ServiceSettings> serviceConfigs; // Поддомены и порты
|
||||
private Map<String, String> envVariables;
|
||||
}
|
||||
|
||||
public ProjectDetailResponse(Project project, ProjectConfig config) {
|
||||
this.project = project;
|
||||
this.config = new ConfigDTO(
|
||||
config.getProjectId(),
|
||||
config.getRawComposeContent(),
|
||||
config.getServiceNames(), // Передаем извлеченные имена
|
||||
config.getServiceConfigs(),
|
||||
config.getEnvVariables()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
package com.jambotronGroup.jambotron.projects;
|
||||
|
||||
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
|
||||
import com.jambotronGroup.jambotron.model.Project;
|
||||
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||
import com.jambotronGroup.jambotron.model.ProjectStatus;
|
||||
import com.jambotronGroup.jambotron.model.User;
|
||||
import com.jambotronGroup.jambotron.model.*;
|
||||
import com.jambotronGroup.jambotron.repository.ProjectConfigRepository;
|
||||
import com.jambotronGroup.jambotron.repository.ProjectRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
@@ -22,11 +20,9 @@ import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -34,151 +30,302 @@ import java.util.stream.Collectors;
|
||||
public class ProjectService {
|
||||
|
||||
private final ProjectRepository projectRepository;
|
||||
|
||||
private final ProjectConfigRepository configRepository;
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class);
|
||||
private static final DateTimeFormatter L_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
// --- Вспомогательные методы логирования ---
|
||||
|
||||
private void logToConsole(UUID projectId, String message) {
|
||||
String timestamp = LocalDateTime.now().format(L_TIME_FORMATTER);
|
||||
String formattedMessage = String.format("[%s] %s", timestamp, message);
|
||||
_logger.info("[Project-{}]: {}", projectId, message);
|
||||
messagingTemplate.convertAndSend("/topic/deploy/" + projectId, formattedMessage);
|
||||
}
|
||||
|
||||
// --- Базовые операции CRUD ---
|
||||
|
||||
public Project save(Project project) {
|
||||
return projectRepository.save(project);
|
||||
}
|
||||
|
||||
public Project findById(UUID id) {
|
||||
return projectRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Project not found with id: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех проектов пользователя (для Dashboard).
|
||||
* Возвращает список проектов, принадлежащих конкретному владельцу.
|
||||
*/
|
||||
public List<Project> findAllByOwnerId(Long ownerId) {
|
||||
_logger.info("Запрос списка проектов для пользователя: {}", ownerId);
|
||||
return projectRepository.findAllByUserId(ownerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение всех проектов в системе (для Admin Panel).
|
||||
* Позволяет администратору видеть состояние всех развернутых мощностей.
|
||||
*/
|
||||
public List<Project> findAllProjectsForAdmin() {
|
||||
_logger.info("Запрос всех проектов системы администратором");
|
||||
return projectRepository.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение развернутой информации о проекте (Project + Config).
|
||||
* Полезно для детального отображения в Dashboard.
|
||||
*/
|
||||
public ProjectDetailResponse getProjectDetails(UUID projectId) {
|
||||
Project project = findById(projectId);
|
||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
|
||||
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
|
||||
|
||||
return new ProjectDetailResponse(project, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное получение деталей проекта.
|
||||
* @param projectId ID проекта
|
||||
* @param currentUserId ID пользователя, совершающего запрос
|
||||
* @param isAdmin флаг, является ли пользователь администратором
|
||||
*/
|
||||
public ProjectDetailResponse getProjectDetailsSecure(UUID projectId, Long currentUserId, boolean isAdmin) {
|
||||
Project project = findById(projectId);
|
||||
|
||||
// ПРОВЕРКА БЕЗОПАСНОСТИ:
|
||||
// Если пользователь не админ и не владелец проекта — доступ запрещен
|
||||
if (!isAdmin && !Objects.equals(project.getUser().getId(), currentUserId)) {
|
||||
_logger.warn("Security Alert: User {} tried to access project {} owned by {}",
|
||||
currentUserId, projectId, project.getUser().getId());
|
||||
throw new AccessDeniedException("У вас нет прав для просмотра этого проекта");
|
||||
}
|
||||
|
||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
|
||||
.orElseThrow(() -> new RuntimeException("Конфигурация не найдена"));
|
||||
|
||||
return new ProjectDetailResponse(project, config);
|
||||
}
|
||||
// --- Управление конфигурацией и создание ---
|
||||
|
||||
@Transactional
|
||||
public Project createProject(String projectName, String rawComposeContent, User owner) {
|
||||
if (projectRepository.existsByNameAndUserId(projectName, owner.getId())) {
|
||||
throw new ProjectAlreadyExistsException(
|
||||
"Проект с именем '" + projectName + "' уже существует у данного пользователя"
|
||||
);
|
||||
throw new RuntimeException("Проект с таким именем уже существует");
|
||||
}
|
||||
|
||||
_logger.info("Валидация пройдена. Создание проекта: {}", projectName);
|
||||
|
||||
Project project = new Project();
|
||||
project.setName(projectName);
|
||||
|
||||
|
||||
|
||||
project.setUser(owner);
|
||||
project.setStatus(ProjectStatus.CREATED);
|
||||
project = projectRepository.save(project);
|
||||
|
||||
UUID projectId = project.getId();
|
||||
|
||||
try {
|
||||
// Используем структуру из GlobalConstants для создания рабочей директории
|
||||
Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(projectId.toString());
|
||||
Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(project.getId().toString());
|
||||
Files.createDirectories(projectDir);
|
||||
|
||||
project.setLocalPath(projectDir.toString());
|
||||
projectRepository.save(project);
|
||||
|
||||
ProjectConfig config = ProjectConfig.builder()
|
||||
.projectId(String.valueOf(project.getId()))
|
||||
.rawComposeContent(rawComposeContent)
|
||||
.serviceNames(extractServiceNames(rawComposeContent))
|
||||
.build();
|
||||
configRepository.save(config);
|
||||
|
||||
return project;
|
||||
return projectRepository.save(project);
|
||||
} catch (IOException e) {
|
||||
_logger.error("FS Error during project creation: {}", projectId, e);
|
||||
_logger.error("Ошибка при создании директории проекта", e);
|
||||
throw new RuntimeException("Ошибка инициализации хранилища");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Основной процесс деплоя.
|
||||
* Метод не помечен @Transactional целиком, чтобы статус STARTING
|
||||
* зафиксировался в БД до начала выполнения долгой команды docker compose.
|
||||
*/
|
||||
public void deployProject(Project project) {
|
||||
@Transactional
|
||||
public void updateProjectConfig(UUID projectId, ProjectConfig newConfig) {
|
||||
ProjectConfig existing = configRepository.findByProjectId(String.valueOf(projectId))
|
||||
.orElseThrow(() -> new RuntimeException("Configuration not found"));
|
||||
|
||||
_logger.info("invoke deployProject");
|
||||
// 1. Устанавливаем промежуточный статус
|
||||
updateStatusInNewTransaction(project.getId(), ProjectStatus.STARTING);
|
||||
_logger.info("{}: chenget status to{}",project.getId(),project.getStatus());
|
||||
try {
|
||||
// 2. Путь берем из сущности (он был инициализирован в createProject через GlobalConstants)
|
||||
Path projectPath = Paths.get(project.getLocalPath());
|
||||
// Валидация поддоменов перед сохранением
|
||||
validateSubdomains(newConfig);
|
||||
|
||||
// 3. Генерируем файлы конфигурации
|
||||
prepareFiles(project, projectPath);
|
||||
_logger.info("Ready to deploy project at: {}", projectPath);
|
||||
existing.setRawComposeContent(newConfig.getRawComposeContent());
|
||||
existing.setServiceConfigs(newConfig.getServiceConfigs());
|
||||
existing.setServiceNames(extractServiceNames(newConfig.getRawComposeContent()));
|
||||
existing.setVersion(existing.getVersion() + 1);
|
||||
configRepository.save(existing);
|
||||
}
|
||||
|
||||
// 4. Запускаем Docker Compose (блокирующая операция)
|
||||
int exitCode = runComposeUp(projectPath,project.getId());
|
||||
|
||||
// 5. Финализируем статус на основе результата
|
||||
if (exitCode == 0) {
|
||||
updateStatusInNewTransaction(project.getId(), ProjectStatus.RUNNING);
|
||||
_logger.info("Project {} is now RUNNING", project.getId());
|
||||
} else {
|
||||
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR);
|
||||
_logger.error("Docker Compose failed with exit code: {}", exitCode);
|
||||
private void validateSubdomains(ProjectConfig config) {
|
||||
if (config.getServiceConfigs() == null) return;
|
||||
for (var entry : config.getServiceConfigs().values()) {
|
||||
String sub = entry.getSubdomain();
|
||||
if (sub != null && !sub.isEmpty()) {
|
||||
if (!sub.matches("^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")) {
|
||||
throw new RuntimeException("Некорректный формат поддомена (только латиница, цифры и дефис): " + sub);
|
||||
}
|
||||
// Проверка на занятость поддомена другими проектами
|
||||
List<ProjectConfig> conflicts = configRepository.findAllByAnyServiceSubdomain(sub);
|
||||
if (conflicts.stream().anyMatch(c -> !c.getProjectId().equals(config.getProjectId()))) {
|
||||
throw new RuntimeException("Поддомен '" + sub + "' уже занят!");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
_logger.error("Critical error during deploy: {}", e.getMessage());
|
||||
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> extractServiceNames(String yamlContent) {
|
||||
try {
|
||||
Yaml yaml = new Yaml();
|
||||
Map<String, Object> obj = yaml.load(yamlContent);
|
||||
Map<String, Object> services = (Map<String, Object>) obj.get("services");
|
||||
return services != null ? new ArrayList<>(services.keySet()) : List.of();
|
||||
} catch (Exception e) { return List.of(); }
|
||||
}
|
||||
|
||||
// --- Жизненный цикл (Deploy, Stop, Delete) ---
|
||||
|
||||
@Transactional
|
||||
public void deployProject(Project project) {
|
||||
UUID projectId = project.getId();
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
Path projectPath = Paths.get(project.getLocalPath());
|
||||
|
||||
logToConsole(projectId, "Инициация созидания окружения...");
|
||||
updateStatusInNewTransaction(projectId, ProjectStatus.STARTING);
|
||||
|
||||
// 1. Извлекаем доменные настройки из MongoDB
|
||||
ProjectConfig config = configRepository.findByProjectId(projectIdStr).orElse(null);
|
||||
|
||||
// 2. Внедряем метки Caddy в пользовательский docker-compose.yml
|
||||
assert config != null;
|
||||
String finalComposeContent = injectCaddyLabels(config.getRawComposeContent(), config);
|
||||
|
||||
try {
|
||||
Files.createDirectories(projectPath);
|
||||
|
||||
// Записываем уже модифицированный файл со всеми доменами
|
||||
Files.writeString(projectPath.resolve("docker-compose.yml"), finalComposeContent);
|
||||
|
||||
logToConsole(projectId, "Конфигурация Caddy подготовлена. Запуск контейнеров...");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d", "--remove-orphans");
|
||||
pb.directory(projectPath.toFile());
|
||||
Process process = pb.start();
|
||||
|
||||
readProcessOutputAsync(projectIdStr, process);
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode == 0) {
|
||||
project.setStatus(ProjectStatus.RUNNING);
|
||||
logToConsole(projectId, "Все службы успешно запущены и подключены к шлюзу Caddy.");
|
||||
} else {
|
||||
project.setStatus(ProjectStatus.ERROR);
|
||||
logToConsole(projectId, "Ошибка сборки стека. Код возврата: " + exitCode);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
project.setStatus(ProjectStatus.ERROR);
|
||||
logToConsole(projectId, "Критическая ошибка: " + e.getMessage());
|
||||
}
|
||||
projectRepository.save(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вспомогательный метод для атомарного обновления статуса
|
||||
* Обогащает исходный docker-compose.yml метками для Caddy reverse proxy
|
||||
* на основе конфигурации проекта из MongoDB.
|
||||
*/
|
||||
public String injectCaddyLabels(String rawComposeContent, ProjectConfig config) {
|
||||
if (config == null || config.getServiceConfigs() == null || config.getServiceConfigs().isEmpty()) {
|
||||
return rawComposeContent;
|
||||
}
|
||||
|
||||
try {
|
||||
Yaml yaml = new Yaml();
|
||||
Map<String, Object> composeMap = yaml.load(rawComposeContent);
|
||||
|
||||
Map<String, Object> services = (Map<String, Object>) composeMap.get("services");
|
||||
if (services == null) {
|
||||
return rawComposeContent;
|
||||
}
|
||||
|
||||
// Перебираем конфигурации сервисов, сохраненные в MongoDB
|
||||
config.getServiceConfigs().forEach((serviceName, serviceSetting) -> {
|
||||
if (services.containsKey(serviceName) && serviceSetting.getSubdomain() != null && !serviceSetting.getSubdomain().isEmpty()) {
|
||||
Map<String, Object> service = (Map<String, Object>) services.get(serviceName);
|
||||
|
||||
// Получаем существующий блок labels или создаем новый, если его нет
|
||||
Map<String, String> labels = (Map<String, String>) service.get("labels");
|
||||
if (labels == null) {
|
||||
labels = new HashMap<>();
|
||||
}
|
||||
|
||||
String domain = serviceSetting.getSubdomain();
|
||||
// Если внутренний порт не задан, по умолчанию шлем на 80
|
||||
String internalPort = serviceSetting.getPort() != null ? serviceSetting.getPort().toString() : "80";
|
||||
|
||||
// Прописываем директивы созидания для caddy-docker-proxy
|
||||
labels.put("caddy", domain);
|
||||
labels.put("caddy.reverse_proxy", "{{upstreams " + internalPort + "}}");
|
||||
|
||||
service.put("labels", labels);
|
||||
}
|
||||
});
|
||||
|
||||
// Настраиваем красивый вывод структуры YAML (BLOCK style)
|
||||
DumperOptions options = new DumperOptions();
|
||||
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
Yaml dumper = new Yaml(options);
|
||||
|
||||
return dumper.dump(composeMap);
|
||||
|
||||
} catch (Exception e) {
|
||||
_logger.error("Ошибка при модификации YAML структуры для Caddy", e);
|
||||
// Если упал парсинг, возвращаем исходный текст, чтобы не сломать деплой целиком
|
||||
return rawComposeContent;
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareEnhancedFiles(Project project, Path projectPath) throws IOException {
|
||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(project.getId()))
|
||||
.orElseThrow(() -> new RuntimeException("Config not found"));
|
||||
|
||||
if (!Files.exists(projectPath)) Files.createDirectories(projectPath);
|
||||
|
||||
Yaml yaml = new Yaml(getDumperOptions());
|
||||
Map<String, Object> compose = yaml.load(config.getRawComposeContent());
|
||||
Map<String, Object> services = (Map<String, Object>) compose.get("services");
|
||||
|
||||
if (services != null) {
|
||||
services.forEach((name, obj) -> {
|
||||
Map<String, Object> service = (Map<String, Object>) obj;
|
||||
// Уникальное имя контейнера для изоляции
|
||||
service.put("container_name", "jambotron-" + project.getId() + "-" + name);
|
||||
|
||||
// Инъекция меток Caddy для автоматического SSL и проксирования
|
||||
if (config.getServiceConfigs() != null && config.getServiceConfigs().containsKey(name)) {
|
||||
var settings = config.getServiceConfigs().get(name);
|
||||
if (settings.getSubdomain() != null && !settings.getSubdomain().isEmpty()) {
|
||||
Map<String, String> labels = (Map<String, String>) service.getOrDefault("labels", new HashMap<String, String>());
|
||||
labels.put("caddy", settings.getSubdomain() + ".jambotron.com");
|
||||
labels.put("caddy.reverse_proxy", "{{upstreams " + settings.getPort() + "}}");
|
||||
service.put("labels", labels);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Files.writeString(projectPath.resolve("docker-compose.yml"), yaml.dump(compose));
|
||||
}
|
||||
|
||||
private DumperOptions getDumperOptions() {
|
||||
DumperOptions options = new DumperOptions();
|
||||
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
options.setPrettyFlow(true);
|
||||
return options;
|
||||
}
|
||||
|
||||
private int runComposeUp(Path projectPath, UUID projectId) {
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d", "--build");
|
||||
pb.directory(projectPath.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
try {
|
||||
Process process = pb.start();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
messagingTemplate.convertAndSend("/topic/deploy/" + projectId, line);
|
||||
}
|
||||
}
|
||||
return process.waitFor();
|
||||
} catch (Exception e) { return -1; }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void stopProject(Project project) {
|
||||
UUID projectId = project.getId();
|
||||
logToConsole(projectId, "Остановка служб...");
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
|
||||
pb.directory(Paths.get(project.getLocalPath()).toFile());
|
||||
if (pb.start().waitFor() == 0) {
|
||||
project.setStatus(ProjectStatus.STOPPED);
|
||||
logToConsole(projectId, "Службы остановлены.");
|
||||
}
|
||||
} catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); }
|
||||
projectRepository.save(project);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteProject(UUID projectId) {
|
||||
Project project = findById(projectId);
|
||||
Path projectPath = Paths.get(project.getLocalPath());
|
||||
logToConsole(projectId, "ПОЛНОЕ УДАЛЕНИЕ ресурсов...");
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down", "-v", "--rmi", "all");
|
||||
pb.directory(projectPath.toFile());
|
||||
pb.start().waitFor();
|
||||
|
||||
if (Files.exists(projectPath)) {
|
||||
try (var stream = Files.walk(projectPath)) {
|
||||
stream.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(java.io.File::delete);
|
||||
}
|
||||
}
|
||||
configRepository.deleteByProjectId(String.valueOf(projectId));
|
||||
projectRepository.delete(project);
|
||||
logToConsole(projectId, "Проект удален.");
|
||||
} catch (Exception e) { logToConsole(projectId, "Ошибка: " + e.getMessage()); }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) {
|
||||
Project project = findById(projectId);
|
||||
@@ -186,191 +333,118 @@ public class ProjectService {
|
||||
projectRepository.save(project);
|
||||
}
|
||||
|
||||
private void prepareFiles(Project project, Path projectPath) throws IOException {
|
||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(project.getId()))
|
||||
.orElseThrow(() -> new RuntimeException("Config not found"));
|
||||
|
||||
// Убеждаемся, что директория существует
|
||||
if (!Files.exists(projectPath)) {
|
||||
Files.createDirectories(projectPath);
|
||||
}
|
||||
|
||||
// docker-compose.yml
|
||||
Path composeFile = projectPath.resolve("docker-compose.yml");
|
||||
Files.writeString(composeFile, config.getRawComposeContent());
|
||||
|
||||
// .env
|
||||
if (config.getEnvVariables() != null && !config.getEnvVariables().isEmpty()) {
|
||||
String envContent = config.getEnvVariables().entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + e.getValue())
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
Files.writeString(projectPath.resolve(".env"), envContent);
|
||||
_logger.info(".env file generated for project: {}", project.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private int runComposeUp(Path projectPath, UUID projectId) {
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d");
|
||||
pb.directory(projectPath.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
/**
|
||||
* Получение актуального списка ВСЕХ контейнеров для конкретного проекта.
|
||||
* Добавлен флаг --all, чтобы показывать даже выключенные службы.
|
||||
*/
|
||||
public List<ContainerStatus> getProjectContainers(UUID projectId) {
|
||||
Project project = findById(projectId);
|
||||
List<ContainerStatus> statuses = new ArrayList<>();
|
||||
|
||||
try {
|
||||
_logger.info("Запуск docker compose up для проекта: {}", projectId);
|
||||
// Флаг --all заставляет docker compose вернуть информацию обо всех контейнерах стека
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "ps", "--all", "--format", "json");
|
||||
pb.directory(Paths.get(project.getLocalPath()).toFile());
|
||||
Process process = pb.start();
|
||||
|
||||
// 1. Читаем поток вывода в реальном времени
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream()))) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
// Логируем в консоль сервера (из этого потока наш DockerLogsHandler
|
||||
// будет забирать данные для передачи в Angular)
|
||||
_logger.info("[Docker-{}]: {}", projectId, line);
|
||||
if (line.trim().isEmpty()) continue;
|
||||
|
||||
// УДАЛЕНО: messagingTemplate.convertAndSend(...)
|
||||
// Теперь логи транслирует DockerLogsHandler напрямую из Docker-сокета.
|
||||
// Парсим строку вывода и собираем неизменяемый record
|
||||
statuses.add(new ContainerStatus(
|
||||
extractValue(line, "ID"),
|
||||
extractValue(line, "Name"),
|
||||
extractValue(line, "Image"),
|
||||
extractValue(line, "State"),
|
||||
extractValue(line, "Status")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
|
||||
// ... остальная логика завершения процесса ...
|
||||
return process.exitValue();
|
||||
|
||||
} catch (IOException | InterruptedException e) {
|
||||
_logger.error("Критическая ошибка при выполнении docker compose: {}", e.getMessage());
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void stopProject(Project project) {
|
||||
Path projectPath = Paths.get(project.getLocalPath());
|
||||
|
||||
_logger.info("Stopping project: {}", project.getId());
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
|
||||
pb.directory(projectPath.toFile());
|
||||
pb.inheritIO();
|
||||
|
||||
try {
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if (exitCode == 0) {
|
||||
project.setStatus(ProjectStatus.STOPPED);
|
||||
_logger.info("Project {} stopped successfully.", project.getId());
|
||||
} else {
|
||||
project.setStatus(ProjectStatus.ERROR);
|
||||
_logger.error("Failed to stop project. Exit code: {}", exitCode);
|
||||
}
|
||||
process.waitFor();
|
||||
} catch (Exception e) {
|
||||
project.setStatus(ProjectStatus.ERROR);
|
||||
_logger.error("Error during docker compose down", e);
|
||||
} finally {
|
||||
projectRepository.save(project);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void save(Project project) {
|
||||
projectRepository.save(project);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateProjectConfig(UUID projectId, String newYaml) {
|
||||
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
|
||||
.orElseThrow(() -> new RuntimeException("Config not found for project: " + projectId));
|
||||
config.setRawComposeContent(newYaml);
|
||||
configRepository.save(config);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteProject(UUID projectId) {
|
||||
Project project = findById(projectId);
|
||||
// Путь берем из проинициализированного поля localPath
|
||||
Path projectPath = Paths.get(project.getLocalPath());
|
||||
|
||||
_logger.info("Запуск полного удаления проекта: {} (путь: {})", projectId, projectPath);
|
||||
|
||||
try {
|
||||
// 1. Останавливаем и удаляем всё в Docker (контейнеры, сети и ТОМА)
|
||||
// Флаг -v критически важен для очистки места
|
||||
runCommand(projectPath, "docker", "compose", "down", "-v");
|
||||
|
||||
if (!Files.isWritable(projectPath)) {
|
||||
throw new RuntimeException("Директория проекта доступна только для чтения. Удаление невозможно.");
|
||||
}
|
||||
|
||||
|
||||
// 2. Рекурсивно удаляем папку проекта в файловой системе
|
||||
if (Files.exists(projectPath)) {
|
||||
try (var stream = Files.walk(projectPath)) {
|
||||
stream.sorted(Comparator.reverseOrder()) // Сначала файлы, потом папки
|
||||
.map(Path::toFile)
|
||||
.forEach(file -> {
|
||||
if (!file.delete()) {
|
||||
_logger.warn("Не удалось удалить файл: {}", file.getAbsolutePath());
|
||||
}
|
||||
});
|
||||
}
|
||||
_logger.info("Файловая система очищена для проекта: {}", projectId);
|
||||
}
|
||||
|
||||
// 3. Удаляем данные из БД[cite: 1]
|
||||
// Сначала конфиг (связанная сущность), потом сам проект
|
||||
configRepository.deleteByProjectId(String.valueOf(projectId));
|
||||
projectRepository.delete(project);
|
||||
|
||||
_logger.info("Проект {} успешно удален из системы", projectId);
|
||||
|
||||
} catch (Exception e) {
|
||||
_logger.error("Критическая ошибка при удалении проекта {}: {}", projectId, e.getMessage());
|
||||
throw new RuntimeException("Ошибка при полном удалении проекта", e);
|
||||
_logger.error("Ошибка при получении статусов всех контейнеров для проекта {}", projectId, e);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Универсальный метод для запуска быстрых команд Docker без чтения логов в реальном времени
|
||||
* Перезапуск конкретного контейнера внутри проекта.
|
||||
* Выполняет команду: docker compose restart <containerId>
|
||||
*/
|
||||
private void runCommand(Path directory, String... command) throws IOException, InterruptedException {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(directory.toFile());
|
||||
pb.inheritIO();
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
_logger.warn("Команда {} завершилась с кодом {}", String.join(" ", command), exitCode);
|
||||
}
|
||||
public void restartContainer(UUID projectId, String containerId) {
|
||||
executeContainerAction(projectId, containerId, "restart", "перезапущен");
|
||||
}
|
||||
|
||||
public List<ContainerStatus> getProjectContainers(UUID projectId) {
|
||||
Project project = findById(projectId);
|
||||
Path projectPath = Paths.get(project.getLocalPath());
|
||||
/**
|
||||
* Остановка конкретного контейнера внутри проекта.
|
||||
* Выполняет команду: docker compose stop <containerId>
|
||||
*/
|
||||
public void stopContainer(UUID projectId, String containerId) {
|
||||
executeContainerAction(projectId, containerId, "stop", "остановлен");
|
||||
}
|
||||
|
||||
// Формат: ID | Service Name | Image | State | Status
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"docker", "compose", "ps", "--format", "{{.ID}}|{{.Service}}|{{.Image}}|{{.State}}|{{.Status}}"
|
||||
);
|
||||
pb.directory(projectPath.toFile());
|
||||
/**
|
||||
* Запуск конкретного контейнера внутри проекта.
|
||||
* Выполняет команду: docker compose start <containerId>
|
||||
*/
|
||||
public void startContainer(UUID projectId, String containerId) {
|
||||
executeContainerAction(projectId, containerId, "start", "запущен");
|
||||
}
|
||||
|
||||
/**
|
||||
* Унифицированный приватный метод для выполнения команд над контейнерами Docker Compose.
|
||||
*/
|
||||
private void executeContainerAction(UUID projectId, String containerId, String action, String actionPastTense) {
|
||||
String pIdStr = String.valueOf(projectId);
|
||||
|
||||
logToConsole(projectId, "Инициация операции [" + action + "] для контейнера ID: " + containerId);
|
||||
|
||||
try {
|
||||
// Вызываем напрямую docker stop/start/restart <id> без compose
|
||||
ProcessBuilder pb = new ProcessBuilder("docker", action, containerId);
|
||||
|
||||
Process process = pb.start();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
return reader.lines()
|
||||
.map(line -> {
|
||||
String[] parts = line.split("\\|");
|
||||
if (parts.length >= 5) {
|
||||
return new ContainerStatus(parts[0], parts[1], parts[2], parts[3], parts[4]);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
readProcessOutputAsync(pIdStr, process);
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode == 0) {
|
||||
logToConsole(projectId, "Контейнер " + containerId + " успешно " + actionPastTense + ".");
|
||||
} else {
|
||||
logToConsole(projectId, "Ошибка операции [" + action + "]. Код возврата: " + exitCode);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
_logger.error("Ошибка при получении списка контейнеров для проекта {}: {}", projectId, e.getMessage());
|
||||
return List.of();
|
||||
|
||||
} catch (IOException | InterruptedException e) {
|
||||
_logger.error("Исключение при операции {} над контейнером {} в проекте {}", action, containerId, projectId, e);
|
||||
logToConsole(projectId, "Критическая ошибка созидания: " + e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private String extractValue(String line, String key) {
|
||||
try {
|
||||
String search = "\"" + key + "\":\"";
|
||||
int start = line.indexOf(search);
|
||||
if (start == -1) return "unknown";
|
||||
start += search.length();
|
||||
int end = line.indexOf("\"", start);
|
||||
return line.substring(start, end);
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private void readProcessOutputAsync(String projectIdStr, Process process) {
|
||||
new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
logToConsole(UUID.fromString(projectIdStr), line);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
_logger.error("Ошибка асинхронного чтения логов процесса для проекта {}", projectIdStr, e);
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,24 @@ package com.jambotronGroup.jambotron.repository;
|
||||
|
||||
import com.jambotronGroup.jambotron.model.ProjectConfig;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
import org.springframework.data.mongodb.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface ProjectConfigRepository extends MongoRepository<ProjectConfig, String> {
|
||||
|
||||
// Нам часто нужно будет доставать конфиг именно по ID проекта из Postgres
|
||||
// Достаем конфиг по ID проекта из Postgres
|
||||
Optional<ProjectConfig> findByProjectId(String projectId);
|
||||
|
||||
// Удаление конфига при удалении проекта
|
||||
void deleteByProjectId(String projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск всех конфигураций, где хотя бы у одного сервиса в Map совпадает subdomain.
|
||||
* Используем dot-notation для поиска по значениям в Map (serviceConfigs.values.subdomain).
|
||||
*/
|
||||
@Query("{ 'serviceConfigs.values.subdomain': ?0 }")
|
||||
List<ProjectConfig> findAllByAnyServiceSubdomain(String subdomain);
|
||||
}
|
||||
Reference in New Issue
Block a user