add project
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user