add project

This commit is contained in:
2026-05-05 23:10:05 +03:00
parent b2b066e0d3
commit 53c040161a
46 changed files with 3824 additions and 1073 deletions
@@ -0,0 +1,117 @@
package com.jambotronGroup.jambotron.WebSocket;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.Container;
import com.github.dockerjava.api.model.Frame;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.util.UriTemplate;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class DockerLogsHandler extends TextWebSocketHandler {
private static final Logger _logger = LoggerFactory.getLogger(DockerLogsHandler.class);
private final DockerClient dockerClient;
// ИСПРАВЛЕНО: Используем базовый интерфейс ResultCallback для хранения подписок
private final Map<String, ResultCallback<Frame>> activeSubscriptions = new ConcurrentHashMap<>();
public DockerLogsHandler(DockerClient dockerClient) {
this.dockerClient = dockerClient;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
UriTemplate template = new UriTemplate("/api/ws-jambotron/{projectId}");
Map<String, String> variables = template.match(session.getUri().getPath());
String projectId = variables.get("projectId");
if (projectId != null) {
_logger.info("Установлено WS соединение для проекта: {}", projectId);
startStreamingLogs(session, projectId);
} else {
session.close();
}
}
private void startStreamingLogs(WebSocketSession session, String projectId) {
try {
// 1. Ищем реальный ID контейнера по метке проекта Compose
// По умолчанию Docker Compose использует ID папки (наш projectId) как имя проекта
List<Container> containers = dockerClient.listContainersCmd()
.withLabelFilter(Map.of("com.docker.compose.project", projectId))
.exec();
if (containers.isEmpty()) {
_logger.warn("Контейнеры для проекта {} не найдены в Docker", projectId);
session.sendMessage(new TextMessage("Система: Контейнеры не запущены. Ожидание деплоя..."));
return;
}
// Берем первый контейнер из проекта для трансляции логов
String realContainerId = containers.get(0).getId();
_logger.info("Трансляция логов из контейнера: {}", realContainerId);
// 2. Настраиваем команду логов[cite: 5]
var logCmd = dockerClient.logContainerCmd(realContainerId)
.withStdOut(true)
.withStdErr(true)
//.withFollow(true)
.withTail(100);
// 3. Используем адаптер без опасных приведений типов[cite: 5]
ResultCallback.Adapter<Frame> callback = new ResultCallback.Adapter<>() {
@Override
public void onNext(Frame item) {
try {
if (session.isOpen() && item != null) {
// Декодируем байты лога в строку для терминала Angular[cite: 4, 5]
session.sendMessage(new TextMessage(new String(item.getPayload())));
}
} catch (IOException e) {
this.onComplete();
}
}
@Override
public void onError(Throwable throwable) {
_logger.error("Ошибка в потоке логов: {}", throwable.getMessage());
this.onComplete();
}
};
logCmd.exec(callback);
activeSubscriptions.put(session.getId(), callback);
} catch (Exception e) {
_logger.error("Критическая ошибка при запуске логов: ", e);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) {
// ИСПРАВЛЕНО: Безопасное закрытие через интерфейс[cite: 5]
ResultCallback<Frame> callback = activeSubscriptions.remove(session.getId());
if (callback != null) {
try {
callback.close();
_logger.info("Стрим логов закрыт для сессии: {}", session.getId());
} catch (IOException e) {
_logger.error("Ошибка при закрытии подписки: ", e);
}
}
}
}
@@ -0,0 +1,25 @@
package com.jambotronGroup.jambotron.WebSocket;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final DockerLogsHandler dockerLogsHandler;
public WebSocketConfig(DockerLogsHandler dockerLogsHandler) {
this.dockerLogsHandler = dockerLogsHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(dockerLogsHandler, "/api/ws-jambotron/{projectId}")
// Поскольку есть proxy.conf.json, запросы приходят как Same-Origin.
// Но для стабильности разработки лучше явно указать localhost.
.setAllowedOrigins("http://localhost:8082", "http://localhost:4200");
}
}
@@ -2,63 +2,208 @@ package com.jambotronGroup.jambotron.controllers;
import com.jambotronGroup.jambotron.model.Project;
import com.jambotronGroup.jambotron.model.ProjectStatus;
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.UserRepository;
import com.jambotronGroup.jambotron.security.AuthenticationFacade;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/projects")
@RequestMapping("/api")
@RequiredArgsConstructor
public class ProjectController {
private static final Logger _logger = LoggerFactory.getLogger(ProjectController.class);
private final ProjectService projectService;
@PostMapping
public ResponseEntity<Project> create(@Valid @RequestBody ProjectCreateRequest request) {
Project newProject = projectService.createProject(request);
return ResponseEntity.status(HttpStatus.CREATED).body(newProject);
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);
}
}
@PostMapping("/{id}/deploy")
/**
* Детали проекта (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) {
// 1. Находим проект в БД
// Получаем текущего пользователя (твой метод)
User user = getCurrentUser();
// Используем метод сервиса findById, а не репозиторий напрямую!
Project project = projectService.findById(id);
// 2. Вызываем магию деплоя
projectService.deployProject(project);
// Выполняем проверку безопасности (через String.valueOf, как мы выяснили)
if (checkIfAdmin(user) ||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
return ResponseEntity.ok("Deployment started for project: " + project.getName());
// ВАЖНО: В твоем сервисе метод называется deployProject(Project project)
projectService.deployProject(project);
return ResponseEntity.ok("Процесс созидания запущен...");
}
return ResponseEntity.status(403).body("Access Denied");
}
@PostMapping("/{id}/stop")
/**
* Остановка проекта (Docker Compose Down). Доступно владельцу или админу[cite: 1, 4].
*/
@PostMapping("/user/projects/{id}/stop")
public ResponseEntity<String> stopProject(@PathVariable UUID id) {
// 1. Ищем проект в базе
User user = getCurrentUser();
Project project = projectService.findById(id);
// 2. Останавливаем контейнеры
projectService.stopProject(project);
if (!checkIfAdmin(user) && !project.getUser().getId().equals(String.valueOf(user.getId()))) {
return new ResponseEntity<>("Access Denied", HttpStatus.FORBIDDEN);
}
// 3. (Опционально) Обновляем статус в базе
projectService.stopProject(project);
project.setStatus(ProjectStatus.STOPPED);
projectService.save(project); // Не забудь добавить метод save в сервис
projectService.save(project);
return ResponseEntity.ok("Project stopped: " + project.getName());
}
@PutMapping("/{id}/config")
public ResponseEntity<String> updateConfig(
@PathVariable UUID id,
@RequestBody String newContent) {
/**
* Удаление проекта (Очистка 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) {
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);
}
// Обновляем данные в базе
projectService.updateProjectConfig(id, newContent);
return ResponseEntity.ok("Configuration updated.");
}
return ResponseEntity.ok("Configuration updated. Re-deploy to apply changes.");
//-------------------- Вспомогательные методы ----------------------------
private User getCurrentUser() {
return userRepository.findById(authenticationFacade.getUserDetails().getId())
.orElseThrow(() -> new RuntimeException("Current user not found"));
}
private boolean checkIfAdmin(User user) {
return user.getRoles() != null && (user.getRoles().equals("ADMIN") || user.getRoles().equals("ROLE_ADMIN"));
}
@GetMapping("/user/projects/{id}/containers")
public ResponseEntity<List<ContainerStatus>> getContainers(@PathVariable UUID id) {
User user = getCurrentUser();
Project project = projectService.findById(id);
// Применяем нашу "золотую формулу" сравнения ID
if (checkIfAdmin(user) ||
String.valueOf(project.getUser().getId()).equals(String.valueOf(user.getId()))) {
return ResponseEntity.ok(projectService.getProjectContainers(id));
}
return ResponseEntity.status(403).build();
}
}
@@ -18,8 +18,9 @@ public class Project {
@Column(nullable = false)
private String name;
@Column(name = "owner_id", nullable = false)
private UUID ownerId;
@ManyToOne(fetch = FetchType.EAGER)//, cascade = CascadeType.PERSIST)
@JoinColumn(name = "userID", nullable = false)
private User user;
@Column(name = "local_path")
private String localPath; // Путь к папке проекта на диске
@@ -1,9 +1,10 @@
package com.jambotronGroup.jambotron.model;
public enum ProjectStatus {
CREATED, // Проект инициализирован в БД
DEPLOYING, // Запущен процесс docker-compose up
RUNNING, // Контейнеры активны
STOPPED, // Контейнеры остановлены
ERROR // Ошибка инфраструктуры
CREATED, // Папка создана, конфиги в Mongo есть
STARTING, // Процесс docker compose up запущен
RUNNING, // Контейнеры успешно поднялись
ERROR, // Docker вернул exit code != 0[cite: 3]
STOPPING, // Выполняется docker compose down[cite: 3]
STOPPED // Проект успешно остановлен
}
@@ -0,0 +1,37 @@
package com.jambotronGroup.jambotron.projects;
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
public class ConfigService {
public void generateEnvFile(UUID projectUuid, Map<String, String> configMap) {
// 1. Формируем путь к папке configs через GlobalConstants
Path configPath = GlobalConstants.PATH_PROJECTS
.resolve(projectUuid.toString())
.resolve("configs");
// 2. Подготавливаем содержимое .env файла
String envContent = configMap.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(Collectors.joining("\n"));
try {
// 3. Создаем директорию, если её нет
Files.createDirectories(configPath);
// 4. Записываем файл project.env
Path envFile = configPath.resolve("project.env");
Files.writeString(envFile, envContent);
System.out.println("Конфиг успешно записан: " + envFile.toAbsolutePath());
} catch (IOException e) {
throw new RuntimeException("Ошибка записи конфига для проекта " + projectUuid, e);
}
}
}
@@ -0,0 +1,9 @@
package com.jambotronGroup.jambotron.projects;
public record ContainerStatus(
String id,
String name,
String image,
String state,
String status
) {}
@@ -10,6 +10,6 @@ public record ProjectCreateRequest(
@Size(min = 3, max = 50, message = "Имя проекта должно быть от 3 до 50 символов")
String name,
@NotNull(message = "ID владельца обязателен")
UUID ownerId
@NotBlank(message = " not null")
String rawComposeContent
) {}
@@ -0,0 +1,9 @@
package com.jambotronGroup.jambotron.projects;
import com.jambotronGroup.jambotron.model.Project;
import com.jambotronGroup.jambotron.model.ProjectConfig;
public record ProjectDetailResponse(
Project project,
ProjectConfig config
) {}
@@ -1,9 +1,10 @@
package com.jambotronGroup.jambotron.projects;
import com.jambotronGroup.jambotron.globalConstants.GlobalConstants;
import com.jambotronGroup.jambotron.initData.InitDataService;
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.repository.ProjectConfigRepository;
import com.jambotronGroup.jambotron.repository.ProjectRepository;
import jakarta.transaction.Transactional;
@@ -11,15 +12,22 @@ import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.BufferedReader;
import java.io.IOException;
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.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -29,6 +37,7 @@ public class ProjectService {
private final ProjectConfigRepository configRepository;
private static final Logger _logger = LoggerFactory.getLogger(ProjectService.class);
public Project findById(UUID id) {
@@ -36,92 +45,210 @@ public class ProjectService {
.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(ProjectCreateRequest request) {
// 1. Проверка на уникальность имени внутри аккаунта пользователя
if (projectRepository.existsByNameAndOwnerId(request.name(), request.ownerId())) {
public Project createProject(String projectName, String rawComposeContent, User owner) {
if (projectRepository.existsByNameAndUserId(projectName, owner.getId())) {
throw new ProjectAlreadyExistsException(
"Проект с именем '" + request.name() + "' уже существует у данного пользователя"
"Проект с именем '" + projectName + "' уже существует у данного пользователя"
);
}
_logger.info("Валидация пройдена. Создание проекта: {}", request.name());
_logger.info("Валидация пройдена. Создание проекта: {}", projectName);
Project project = new Project();
project.setName(request.name());
project.setOwnerId(request.ownerId());
project.setName(projectName);
project.setUser(owner);
project.setStatus(ProjectStatus.CREATED);
project = projectRepository.save(project);
UUID projectId = project.getId();
try {
// 2. FS: Используем GlobalConstants[cite: 1]
// Используем структуру из GlobalConstants для создания рабочей директории
Path projectDir = GlobalConstants.PATH_PROJECTS.resolve(projectId.toString());
Files.createDirectories(projectDir);
project.setLocalPath(projectDir.toString());
projectRepository.save(project);
// 3. MongoDB[cite: 1]
ProjectConfig config = ProjectConfig.builder()
.projectId(String.valueOf(project.getId()))
.rawComposeContent("version: '3.8'\nservices:\n app:\n image: nginx:alpine\n ports:\n - \"8080:80\"")
.rawComposeContent(rawComposeContent)
.build();
configRepository.save(config);
return project;
} catch (IOException e) {
_logger.error("FS Error: {}", projectId, e);
_logger.error("FS Error during project creation: {}", projectId, e);
throw new RuntimeException("Ошибка инициализации хранилища");
}
}
/**
* Основной процесс деплоя.
* Метод не помечен @Transactional целиком, чтобы статус STARTING
* зафиксировался в БД до начала выполнения долгой команды docker compose.
*/
public void deployProject(Project project) {
// 1. Получаем конфиг из Mongo
ProjectConfig config = configRepository.findByProjectId(String.valueOf(project.getId()))
.orElseThrow(() -> new RuntimeException("Config not found"));
// 2. Путь к папке проекта (уже создан при POST-запросе)
Path projectPath = Paths.get(project.getLocalPath());
File composeFile = projectPath.resolve("docker-compose.yml").toFile();
_logger.info("invoke deployProject");
// 1. Устанавливаем промежуточный статус
updateStatusInNewTransaction(project.getId(), ProjectStatus.STARTING);
_logger.info("{}: chenget status to{}",project.getId(),project.getStatus());
try {
// 3. Записываем YAML контент в файл
Files.writeString(composeFile.toPath(), config.getRawComposeContent());
_logger.info("Docker Compose file created at: {}", composeFile.getAbsolutePath());
// 2. Путь берем из сущности (он был инициализирован в createProject через GlobalConstants)
Path projectPath = Paths.get(project.getLocalPath());
// 4. Запускаем проект через ProcessBuilder (команда docker compose up)
runComposeUp(projectPath);
// 3. Генерируем файлы конфигурации
prepareFiles(project, projectPath);
_logger.info("Ready to deploy project at: {}", projectPath);
} catch (IOException e) {
throw new RuntimeException("Failed to deploy project files", e);
}
}
// 4. Запускаем Docker Compose (блокирующая операция)
int exitCode = runComposeUp(projectPath,project.getId());
private void runComposeUp(Path projectPath) {
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "up", "-d");
pb.directory(projectPath.toFile()); // Выполняем команду внутри папки проекта
pb.inheritIO(); // Перенаправляем логи докера в консоль Spring
try {
Process process = pb.start();
int exitCode = process.waitFor();
// 5. Финализируем статус на основе результата
if (exitCode == 0) {
_logger.info("Project deployed successfully via Docker Compose!");
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);
}
} catch (Exception e) {
_logger.error("Error executing Docker Compose", e);
_logger.error("Critical error during deploy: {}", e.getMessage());
updateStatusInNewTransaction(project.getId(), ProjectStatus.ERROR);
}
}
/**
* Вспомогательный метод для атомарного обновления статуса
*/
@Transactional
public void updateStatusInNewTransaction(UUID projectId, ProjectStatus status) {
Project project = findById(projectId);
project.setStatus(status);
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);
try {
_logger.info("Запуск docker compose up для проекта: {}", projectId);
Process process = pb.start();
// 1. Читаем поток вывода в реальном времени
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
// Логируем в консоль сервера (из этого потока наш DockerLogsHandler
// будет забирать данные для передачи в Angular)
_logger.info("[Docker-{}]: {}", projectId, line);
// УДАЛЕНО: messagingTemplate.convertAndSend(...)
// Теперь логи транслирует DockerLogsHandler напрямую из Docker-сокета.
}
}
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 для выполнения docker compose down
ProcessBuilder pb = new ProcessBuilder("docker", "compose", "down");
pb.directory(projectPath.toFile());
pb.inheritIO();
@@ -131,30 +258,119 @@ public class ProjectService {
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);
}
} catch (Exception e) {
project.setStatus(ProjectStatus.ERROR);
_logger.error("Error during docker compose down", e);
throw new RuntimeException("Could not stop docker project", e);
} finally {
projectRepository.save(project);
}
}
@Transactional
public void save(Project project) {
projectRepository.save(project);
}
@Transactional
public void updateProjectConfig(UUID projectId, String newYaml) {
// 1. Ищем конфиг в MongoDB
ProjectConfig config = configRepository.findByProjectId(String.valueOf(projectId))
.orElseThrow(() -> new RuntimeException("Config not found for project: " + projectId));
// 2. Обновляем поле с сырым YAML
config.setRawComposeContent(newYaml);
// 3. Сохраняем обратно в Mongo
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);
}
}
/**
* Универсальный метод для запуска быстрых команд Docker без чтения логов в реальном времени
*/
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 List<ContainerStatus> getProjectContainers(UUID projectId) {
Project project = findById(projectId);
Path projectPath = Paths.get(project.getLocalPath());
// Формат: ID | Service Name | Image | State | Status
ProcessBuilder pb = new ProcessBuilder(
"docker", "compose", "ps", "--format", "{{.ID}}|{{.Service}}|{{.Image}}|{{.State}}|{{.Status}}"
);
pb.directory(projectPath.toFile());
try {
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());
}
} catch (IOException e) {
_logger.error("Ошибка при получении списка контейнеров для проекта {}: {}", projectId, e.getMessage());
return List.of();
}
}
}
@@ -10,8 +10,9 @@ import java.util.UUID;
public interface ProjectRepository extends JpaRepository<Project, UUID> {
// Поиск всех проектов конкретного пользователя
List<Project> findAllByOwnerId(UUID ownerId);
List<Project> findAllByUserId(Long userId);
// Поиск по имени (например, для проверки уникальности внутри аккаунта)
boolean existsByNameAndOwnerId(String name, UUID ownerId);
boolean existsByNameAndUserId(String name, Long userId);
}
@@ -117,8 +117,13 @@ public class WebSecurityConfig implements WebMvcConfigurer {// extends WebSecuri
.requestMatchers("/api/public/zhipuai/image/**").permitAll()
.requestMatchers("/api/v1/projects").permitAll()
.requestMatchers("/api/v1/projects/**").permitAll()
.requestMatchers("/api/ws-jambotron/**").permitAll()
.requestMatchers("/api/user/projects").permitAll()
.requestMatchers("/api/user/projects/**").permitAll()
.requestMatchers("/api/admin/projects").permitAll()
.requestMatchers("/api/admin/projects/**").permitAll()
// Access permitted for specific roles
.requestMatchers("/api/user/**").hasRole("USER")
@@ -137,10 +142,10 @@ public class WebSecurityConfig implements WebMvcConfigurer {// extends WebSecuri
);
if(_environment.getProperty("server.port") != null &&
_environment.getProperty("server.port").equalsIgnoreCase("443")) {
http.redirectToHttps(withDefaults());
}
// if(_environment.getProperty("server.port") != null &&
// _environment.getProperty("server.port").equalsIgnoreCase("443")) {
// http.redirectToHttps(withDefaults());
// }
http.authenticationProvider(authenticationProvider());
@@ -1,7 +1,7 @@
CREATE TABLE projects (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
owner_id UUID NOT NULL,
userID integer NOT NULL,
local_path TEXT, -- Путь к папке проекта
status VARCHAR(20), -- CREATED, RUNNING, ERROR
created_at TIMESTAMP DEFAULT NOW()