Запуск init-скрипта при старте task-а

This commit is contained in:
Anton Dzyk 2025-12-21 13:14:09 +03:00
parent ec3f3318ac
commit 05c04c49ed
13 changed files with 316 additions and 198 deletions

View File

@ -2,6 +2,7 @@ package ru.oa2.lti;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;
import ru.oa2.lti.infrastructure.runner.Runner;
import ru.oa2.lti.model.LtiLogin;
import ru.oa2.lti.model.TaskData;
import ru.oa2.lti.repository.LMSContentRepository;
@ -19,11 +20,14 @@ public class ApplicationService {
private final JwtService jwtService;
private final LMSContentRepository lmsContentRepository;
private final Runner runner;
public ApplicationService(JwtService jwtService,
LMSContentRepository lmsContentRepository) {
LMSContentRepository lmsContentRepository,
Runner runner) {
this.jwtService = jwtService;
this.lmsContentRepository = lmsContentRepository;
this.runner = runner;
}
public Payload getPayload(LtiLogin ltiLogin) {
@ -41,7 +45,10 @@ public class ApplicationService {
//TODO добавить версию в Task и выбирать самую старшую и опубликованную
TaskData data = tasks.stream().findFirst().get().getData();
//TODO запуск initScript
if (!runner.run(contextId, data.getInitScript())) {
return "Init script FAILED";
}
return data.getDescription();
} else {
return "Not Page";
@ -50,7 +57,6 @@ public class ApplicationService {
public String saveResult(String body) {
//TODO
return "{\n" +
" \"success\": true,\n" +
" \"message\": \"Результат успешно получен и обработан\",\n" +

View File

@ -1,31 +1,28 @@
package ru.oa2.lti.controller;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import ru.oa2.lti.ApplicationService;
import ru.oa2.lti.infrastructure.LMSService;
import ru.oa2.lti.model.LtiLogin;
import ru.oa2.lti.service.jwt.Payload;
import ru.oa2.lti.service.jwt.TokenService;
@Slf4j
@Controller
@RequestMapping("/tool/lti")
public class LoginController {
public class LaunchController {
private final ApplicationService service;
private final TokenService tokenService;
private final LMSService lmsService;
public LoginController(ApplicationService applicationService,
TokenService tokenService) {
public LaunchController(ApplicationService applicationService,
LMSService lmsService) {
this.service = applicationService;
this.tokenService = tokenService;
this.lmsService = lmsService;
}
@ResponseBody
@ -36,9 +33,7 @@ public class LoginController {
@RequestParam("lti_deployment_id") String ltiDeploymentId,
@RequestParam("lti_message_hint") String ltiMessageHint,
@RequestParam("target_link_uri") String targetLinkUri,
HttpServletRequest request,
RedirectAttributes redirectAttributes,
ServletResponse servletResponse) {
HttpServletRequest request) {
var ltiLogin = LtiLogin.builder()
.clientId(clientId)
@ -62,8 +57,7 @@ public class LoginController {
session.setAttribute("payload", payload);
if (payload.getContextId() != null) {
var body = tokenService.ltiAuth(ltiMessageHint, iss, loginHint);
return body.getBody();
return lmsService.ltiAuth(ltiMessageHint, iss, loginHint);
}
return "redirect:/tool/lti/select";
@ -85,8 +79,6 @@ public class LoginController {
}
model.addAttribute("description", data);
model.addAttribute("codeTitle", "Dockerfile:");
model.addAttribute("inputCode", "FROM ...");
}
return "task";

View File

@ -3,21 +3,17 @@ package ru.oa2.lti.controller;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.web.client.RestClientBuilderConfigurer;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import ru.oa2.lti.ApplicationService;
import ru.oa2.lti.model.LtiLogin;
import ru.oa2.lti.service.jwt.JwtService;
import ru.oa2.lti.service.jwt.TokenService;
import ru.oa2.lti.service.results.ResultService;
import java.util.UUID;
@Slf4j
@RestController
@RequestMapping("/tool/lti/result")
@ -47,6 +43,7 @@ public class ResultController {
HttpSession session = req.getSession(false);
if (session == null) return ResponseEntity.status(401).build();
//TODO запуск скрипта проверки
var ltiLogin = (LtiLogin) session.getAttribute("lti_login");
var assessToken = tokenService.exchangeForAccessToken(
ltiLogin.getClientId());

View File

@ -1,5 +0,0 @@
```shell
curl -XPOST http://localhost:9999/tool/lti/login \
-d 'client_id=27e2d42d-d218-4ab9-b063-85e3ec87ec8f&iss=https%3A%2F%2Flocalhost&login_hint=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImU5ODA3YTg2LTVhNWQtNDI0MC1hYjI1LWNhYTUyZDliZjUxOSJ9.eyJkZXBsb3ltZW50S2V5IjoxLCJkZXBsb3ltZW50SWQiOiIyNzE1ZDA0NS03N2M1LTRkODgtYTFlNC05MmQ3MjFmY2M1NGIiLCJjb250ZXh0S2V5IjoxLCJjb250ZXh0SWQiOiJlMmE4ZGQxMy04MWJhLTQwMTEtODgwNi0wMjA5NGZkZjg5ZjMiLCJjb3Vyc2VhZG1pbiI6ZmFsc2UsImNvYWNoIjpmYWxzZSwicGFydGljaXBhbnQiOnRydWV9.vgPLHQA9scdED3_OwOy46h6VmzpN4arIHY3-YDBdhH4kuEqeCOjjtFbGdDqauKoQBTSVo4UvoK4JQLMhak6qsFchCj54mPob8jbaKLd0GnO_jY0sR609Nrk7Muq7cki_4PjVMX8TTHp-VYlSHjVxQH_z_D5Wld27J95z4qJjRU59GmvLGDqdLyerVVBO-zaavYsUbEEiAxoX3hmytxrarmJ7OHpxufNOeXzZ0DSGUmU5ycuTAqxODaHO1Y4rQM6XlvSfDh_TmXP8QEkatlp2cdjRpNWyOdUW_hZfbtkqukwt1ZP7KEgWzNI3vivpBjm2xfUG2YLwXPJqHa47NQgvsQ&lti_deployment_id=2715d045-77c5-4d88-a1e4-92d721fcc54b&lti_message_hint=1802240&target_link_uri=https%3A%2F%2Fopenolat.local%2Ftool'
```

View File

@ -0,0 +1,6 @@
package ru.oa2.lti.infrastructure;
public interface LMSService {
String ltiAuth(String ltiLoginHint, String iss, String loginHint);
}

View File

@ -0,0 +1,40 @@
package ru.oa2.lti.infrastructure;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import ru.oa2.lti.config.AppProperties;
@Slf4j
@Component
public class LMSServiceImpl implements LMSService {
private final RestClient restClient;
private final AppProperties appProperties;
public LMSServiceImpl(RestClient restClient, AppProperties appProperties) {
this.restClient = restClient;
this.appProperties = appProperties;
}
@Override
public String ltiAuth(String ltiLoginHint, String iss, String loginHint) {
var result = restClient
.get()
.uri("/lti/auth?" +
"state=start" +
"&code=code1" +
"&iss=" + iss +
"&login_hint=" + loginHint +
"&redirect_uri=" + appProperties.lmsUrl() + "/tool/lti/redirect" +
"&lti_message_hint=" + ltiLoginHint)
.retrieve();
ResponseEntity<String> body = result.toEntity(String.class);
if (log.isDebugEnabled()) {
log.debug("lti/auth RESPONSE: {}", body);
}
return body.getBody();
}
}

View File

@ -0,0 +1,10 @@
package ru.oa2.lti.infrastructure.runner;
import java.util.UUID;
public interface Runner {
boolean run(UUID userId, String script);
boolean check(UUID userId, String script);
}

View File

@ -0,0 +1,77 @@
package ru.oa2.lti.infrastructure.runner;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
@Slf4j
@Component
public class RunnerImpl implements Runner {
private final String TEMP_DIR = "./temp/";
@Override
public boolean run(UUID userId, String script) {
try {
return runScript(userId, script);
} catch (Exception ex) {
log.error(ex.getMessage());
return false;
}
}
//TODO добавить вывод результата/интерпретации
@Override
public boolean check(UUID userId, String script) {
try {
return runScript(userId, script);
} catch (Exception ex) {
log.error(ex.getMessage());
return false;
}
}
private boolean runScript(UUID userId, String script) throws Exception {
var processBuilder = new ProcessBuilder();
processBuilder.command("bash", saveFile(userId, script));
var process = processBuilder.start();
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream())
);
StringBuilder output = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
log.info(output.toString());
int exitCode = process.waitFor();
return exitCode == 0;
}
private String saveFile(UUID userId, String script) throws IOException {
Path path = Paths.get(TEMP_DIR + userId.toString());
if (!Files.exists(path)) {
Files.createDirectories(path);
}
var tm = System.currentTimeMillis();
var scriptPath = Files.write(
Paths.get(TEMP_DIR + userId + "/" + tm),
script.getBytes(StandardCharsets.UTF_8));
return scriptPath.toString();
}
}

View File

@ -16,11 +16,9 @@ import java.util.Map;
public class TokenService {
private final RestTemplate restTemplate = new RestTemplate();
private final RestClient restClient;
private final AppProperties appProperties;
public TokenService(RestClient restClient, AppProperties appProperties) {
this.restClient = restClient;
public TokenService(AppProperties appProperties) {
this.appProperties = appProperties;
}
@ -65,24 +63,4 @@ public class TokenService {
throw new RuntimeException("Не удалось получить access token", e);
}
}
public ResponseEntity<String> ltiAuth(String ltiLoginHint, String iss, String loginHint) {
var result = restClient
.get()
.uri("/lti/auth?" +
"state=start" +
"&code=code1" +
"&iss=" + iss +
"&login_hint=" + loginHint +
"&redirect_uri=" + appProperties.lmsUrl() + "/tool/lti/redirect" +
"&lti_message_hint=" + ltiLoginHint)
.retrieve();
ResponseEntity<String> body = result.toEntity(String.class);
if (log.isDebugEnabled()) {
log.debug("lti/auth RESPONSE: {}", body);
}
return body;
}
}

View File

@ -0,0 +1,6 @@
package ru.oa2.lti.service.results.dto;
public record ResultResponse(
String status
) {
}

View File

@ -0,0 +1,45 @@
document.getElementById('submitBtn').addEventListener('click', function () {
// Показываем, что загрузка началась
const statusContainer = document.getElementById('statusContainer');
const icon = document.getElementById('icon');
const statusLabel = document.getElementById('statusLabel');
// Получаем данные пользователя
const contextId = document.getElementById('contextId')?.value || 'unknown';
const participantId = document.getElementById('participantId')?.value || 'unknown';
const payload = {
contextId: contextId,
participantId: participantId
};
fetch('/tool/lti/result/docker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(response => {
if (!response.ok) throw new Error(`Ошибка сети: ${response.status}`);
return response.json();
})
.then(data => {
// Показываем статус
statusContainer.style.display = 'flex';
if (data.status === 'success') {
icon.className = 'fa-solid fa-check-circle status-success';
statusLabel.textContent = 'Success';
statusLabel.className = 'status-label status-success';
} else {
icon.className = 'fa-solid fa-xmark-circle status-failed';
statusLabel.textContent = 'Failed';
statusLabel.className = 'status-label status-failed';
}
})
.catch(() => {
statusContainer.style.display = 'flex';
icon.className = 'fa-solid fa-xmark-circle status-failed';
statusLabel.textContent = 'Failed';
statusLabel.className = 'status-label status-failed';
});
});

View File

@ -4,182 +4,116 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body, html {
height: 100%;
margin: 0;
font-family: Arial, sans-serif;
}
.container-fluid {
height: 90vh;
height: 100vh;
display: flex;
flex-direction: column;
padding: 0;
}
.row {
height: 100%;
}
.left-section {
border-right: 2px solid #dee2e6;
padding: 20px;
.content-section {
flex: 1;
padding: 30px 20px;
overflow-y: auto;
background-color: #f8f9fa;
}
.right-section {
padding: 0;
}
.right-top {
height: 50%;
padding: 20px;
border-bottom: 2px solid #dee2e6;
}
.right-bottom {
height: 50%;
padding: 20px;
}
.task-block {
background: white;
padding: 20px;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
line-height: 1.7;
}
.code-block {
background: #f5f5f5;
padding: 15px;
border-radius: 5px;
font-family: 'Courier New', monospace;
margin: 10px 0;
}
.input-output {
height: 100%;
.footer {
padding: 15px 20px;
background-color: #f1f3f5;
border-top: 1px solid #dee2e6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
}
.status-container {
display: flex;
align-items: center;
margin-right: auto;
margin-left: 15px;
}
.status-icon {
font-size: 18px;
margin-right: 8px;
}
.status-success {
color: #28a745;
}
.status-failed {
color: #dc3545;
}
.status-label {
font-size: 14px;
font-weight: 500;
}
.submit-btn {
padding: 10px 24px;
font-size: 15px;
}
textarea {
flex: 1;
resize: none;
font-family: 'Courier New', monospace;
padding: 10px;
}
.output-area {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 15px;
flex: 1;
overflow-y: auto;
font-family: 'Courier New', monospace;
white-space: pre-wrap;
}
.run-button {
width: 100%;
min-height: 200px;
padding: 12px;
margin-top: 15px;
padding: 10px 30px;
font-size: 16px;
border: 1px solid #ced4da;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 14px;
resize: none;
}
.result-title {
margin-bottom: 10px;
color: #495057;
font-weight: bold;
textarea:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
</style>
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Левая секция -->
<div class="col-md-6 left-section">
<h3>Условие задачи</h3>
<div class="task-block" th:utext="${description}">
</div>
</div>
<!-- Правая секция -->
<div class="col-md-6 right-section">
<!-- Верхняя часть правой секции -->
<div class="right-top">
<h4>Введите решение</h4>
<div class="input-output">
<div class="code-prompt mb-2" th:utext="${codeTitle}">
<!-- Текст подсказки для ввода кода -->
</div>
<textarea id="codeInput" class="form-control" th:text="${inputCode}">
</textarea>
</div>
</div>
<!-- Нижняя часть правой секции -->
<div class="right-bottom">
<h4>Результат выполнения</h4>
<div class="input-output">
<div class="result-title">Вывод программы:</div>
<div id="outputArea" class="output-area">
<!-- Здесь будет появляться результат -->
Результат выполнения появится здесь...
</div>
<div class="text-center">
<button id="runButton" class="btn btn-primary run-button">
<i class="fas fa-play"></i> Запустить
</button>
</div>
</div>
</div>
<!-- Описание задачи — на всю ширину -->
<div class="content-section">
<div class="task-block" th:utext="${description}">
<!-- Сюда вставляется HTML-описание задачи -->
</div>
</div>
<!-- Футер: статус + кнопка -->
<div class="footer">
<!-- Статус (иконка + надпись) -->
<div id="statusContainer" class="status-container" style="display: none;">
<div id="statusIcon" class="status-icon">
<i id="icon" class="fa-solid"></i>
</div>
<div id="statusLabel" class="status-label"></div>
</div>
<!-- Кнопка отправки -->
<button id="submitBtn" class="btn btn-primary submit-btn">
<i class="fas fa-paper-plane"></i> Отправить на проверку
</button>
</div>
</div>
<script>
document.getElementById('runButton').addEventListener('click', function() {
const codeInput = document.getElementById('codeInput').value;
const outputArea = document.getElementById('outputArea');
<!-- Скрытые поля (если нужно передать контекст) -->
<input type="hidden" id="contextId" th:value="${contextId}" />
<input type="hidden" id="participantId" th:value="${participantId}" />
// Показываем индикатор загрузки
outputArea.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin"></i> Отправка данных...</div>';
// Получаем данные пользователя и контекста
// Предположим, что эти значения хранятся в data-атрибутах или скрытых полях
const contextId = document.getElementById('contextId')?.value || 'unknown_context';
const participantId = document.getElementById('participantId')?.value || 'unknown_participant';
// Формируем JSON-объект для отправки
const payload = {
contextId: contextId,
participantId: participantId,
text: codeInput
};
fetch('/tool/lti/result/docker', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => {
if (!response.ok) {
throw new Error(`Ошибка сети: ${response.status}`);
}
return response.json();
})
.then(data => {
// Успешный ответ от сервера
outputArea.innerHTML = '<div class="text-success">✓ Данные успешно отправлены!</div>';
console.log('Ответ сервера:', data);
})
.catch(error => {
// Обработка ошибок сети или сервера
outputArea.innerHTML = `
<div class="text-danger">
<strong>Ошибка отправки:</strong><br>
${error.message}
</div>
`;
console.error('Ошибка при отправке:', error);
});
});
// Автоматический рост textarea при вводе
document.getElementById('codeInput').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
</script>
<script th:src="@{/tool/lti/js/main.js}"></script>
</body>
</html>

View File

@ -0,0 +1,32 @@
package ru.oa2.lti.infrastructure;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import ru.oa2.lti.infrastructure.runner.Runner;
import ru.oa2.lti.infrastructure.runner.RunnerImpl;
import java.util.UUID;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
@SpringBootTest(classes = {RunnerImpl.class})
public class RunnerTest {
@Autowired
Runner runner;
@Test
public void runnerTest() {
var script = "#!/bin/bash\n" +
"echo \"Запуск развёртывания...\"\n" +
"sleep 2\n" +
"echo \"Развёртывание завершено.\"";
var result = runner.run(UUID.randomUUID(), script);
assertThat(result).isEqualTo(true);
}
}