blob: 201c8ac8db270380e7434bc9c6459c813125c099 [file] [log] [blame]
/*********************************************************************************
* Copyright (c) 2020 Robert Bosch GmbH and others.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Robert Bosch GmbH - initial API and implementation
********************************************************************************
*/
package org.eclipse.app4mc.cloud.manager;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.http.HttpStatus;
import org.eclipse.app4mc.cloud.manager.administration.CloudServiceDefinition;
import org.eclipse.app4mc.cloud.manager.storage.StorageException;
import org.eclipse.app4mc.cloud.manager.storage.StorageFileNotFoundException;
import org.eclipse.app4mc.cloud.manager.storage.StorageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
@Controller
@SessionAttributes("workflowStatus")
public class WorkflowController {
private final StorageService storageService;
@javax.annotation.Resource(name = "cloudServiceDefinitions")
List<CloudServiceDefinition> cloudServiceDefinitions;
@Autowired
public WorkflowController(StorageService storageService) {
this.storageService = storageService;
}
@GetMapping("/workflow")
public String workflow(Model model) {
// TODO move this to a configuration resource mechanism
CloudServiceDefinition csd = this.cloudServiceDefinitions.stream()
.filter(sd -> sd.getName().equals("Validation"))
.findFirst()
.orElse(null);
List<?> allProfiles = new ArrayList<>();
if (csd != null) {
try {
String baseUrl = csd.getBaseUrl();
if (!baseUrl.endsWith("/")) {
baseUrl += "/";
}
allProfiles = Unirest.get(baseUrl + "profiles").asJson().getBody().getArray().toList();
} catch (Exception e) {
// do nothing, we will handle configurations in a different way in the future
}
}
model.addAttribute("allProfiles", allProfiles);
// render the form view
return "workflow";
}
@PostMapping("/select/{selected}")
public String selectService(
@PathVariable(name = "selected") String selected,
@ModelAttribute WorkflowStatus ws) {
ws.addSelectedService(selected);
// render the form view
return "workflow";
}
@GetMapping("/selectedServices")
public String getSelectedServices() {
// render the servicesList fragment contained in selectedServices.html
return "selectedServices :: servicesList";
}
@PostMapping("/workflow")
public String handleFileUpload(
@RequestParam("file") MultipartFile file,
@RequestParam(name = "services", required = false) String[] services,
@RequestParam(name = "profiles", required = false) String[] validationProfiles,
Model model,
@ModelAttribute WorkflowStatus ws) {
if (ws == null) {
ws = new WorkflowStatus();
} else {
ws.clear();
}
final WorkflowStatus workflowStatus = ws;
if (file.isEmpty()) {
workflowStatus.addError("Select a file to upload");
return "redirect:/workflow";
}
// upload the input file
String uuid = storageService.store(file);
workflowStatus.setUuid(uuid);
workflowStatus.addMessage(file.getOriginalFilename() + " successfully uploaded!");
model.addAttribute("workflowStatus", workflowStatus);
if (services != null) {
Path inputFile = storageService.load(uuid, file.getOriginalFilename());
for (String service : services) {
if (StringUtils.isEmpty(service)) {
continue;
}
// if an error occurred stop the workflow
if (!workflowStatus.getErrors().isEmpty()) {
break;
}
try {
inputFile = executeCloudService(service, workflowStatus, inputFile, file.getOriginalFilename());
} catch (ProcessingFailedException e) {
workflowStatus.addError(e.getMessage());
}
}
}
return "redirect:/workflow";
}
@GetMapping("/{uuid}/files/{servicepath}/{filename:.+}")
@ResponseBody
public ResponseEntity<Resource> serveFile(
@PathVariable String uuid,
@PathVariable String servicepath,
@PathVariable String filename) {
Resource file = storageService.loadAsResource(uuid, servicepath, filename);
return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(file);
// return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
// "attachment; filename=\"" + file.getFilename() + "\"").body(file);
}
@GetMapping("/{uuid}/delete")
public String delete(
@PathVariable String uuid,
@ModelAttribute WorkflowStatus workflowStatus) {
storageService.delete(uuid);
workflowStatus.clear();
return "redirect:/workflow";
}
private Path executeCloudService(String serviceName, WorkflowStatus workflowStatus, Path inputFile, String originalFilename) {
try {
CloudServiceDefinition csd = this.cloudServiceDefinitions.stream()
.filter(sd -> sd.getName().equals(serviceName))
.findFirst()
.orElse(null);
String baseUrl = "";
if (csd != null) {
baseUrl = csd.getBaseUrl();
} else {
workflowStatus.addError("No service with name " + serviceName + " found! Workflow stopped!");
return null;
}
// upload to service
HttpResponse<?> uploadResponse = Unirest.post(baseUrl)
.field("file", Files.newInputStream(inputFile), originalFilename)
.asEmpty();
// extract status link from result
String statusUrl = null;
if (uploadResponse.getStatus() == 201) {
statusUrl = uploadResponse.getHeaders().getFirst("Location");
if (StringUtils.isEmpty(statusUrl)) {
// fallback check for Link header if Location header is not set
statusUrl = getUrlFromLink(uploadResponse.getHeaders().get("Link"), "status", baseUrl);
} else if (!statusUrl.startsWith(baseUrl)) {
throw new ProcessingFailedException(
"The status URL from the Location header does not match with the service base URL '" + baseUrl + "': " + statusUrl);
}
} else if (uploadResponse.getStatus() == 200) {
// fallback if return code is 200, then follow up needs to be placed in Link header
statusUrl = getUrlFromLink(uploadResponse.getHeaders().get("Link"), "status", baseUrl);
} else {
// error
workflowStatus.addError("Upload to " + serviceName + " failed! Error code: " + uploadResponse.getStatus() + " - Workflow stopped!");
return null;
}
workflowStatus.addMessage("Upload to " + serviceName + " service succeeded");
Path result = inputFile;
// trigger status URL until process is finished or error occured
if (statusUrl != null) {
HttpResponse<?> statusResponse = Unirest.get(statusUrl).asEmpty();
List<String> linkHeaders = statusResponse.getHeaders().get("Link");
// TODO make this asynchronous using websockets
long start = System.currentTimeMillis();
long end = System.currentTimeMillis();
while (linkHeaders.size() <= 1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
statusResponse = Unirest.get(statusUrl).asEmpty();
linkHeaders = statusResponse.getHeaders().get("Link");
end = System.currentTimeMillis();
// don't request more than 30 seconds
if (end - start > 30_000) {
break;
}
}
// first check if there is a result link
String downloadUrl = getUrlFromLink(linkHeaders, "result", baseUrl);
String deleteUrl = null;
if (downloadUrl != null) {
workflowStatus.addMessage(serviceName + " processing finished");
// download file
Path migrationSubDir = storageService.load(workflowStatus.getUuid(), "_" + serviceName.toLowerCase());
Files.createDirectories(migrationSubDir);
HttpResponse<File> downloadResponse = Unirest.get(downloadUrl)
.asFile(migrationSubDir.resolve("result").toString());
Path migrationResult = downloadResponse.getBody().toPath();
String filename = getFilenameFromHeader(downloadResponse.getHeaders().getFirst("Content-Disposition"));
if (filename != null) {
migrationResult = Files.move(migrationResult, migrationSubDir.resolve(filename));
}
workflowStatus.addMessage(serviceName + " result downloaded");
workflowStatus.addResult(
serviceName + " Result",
MvcUriComponentsBuilder.fromMethodName(
WorkflowController.class,
"serveFile",
workflowStatus.getUuid(),
"_" + serviceName.toLowerCase(),
migrationResult.getFileName().toString()).build().toUri().toString());
// extract header information if result should be used in workflow
String useResult = downloadResponse.getHeaders().getFirst("x-app4mc-use-result");
if (useResult == null || !useResult.toLowerCase().equals("false")) {
// the result should not be used in the workflow
result = migrationResult;
}
// check the response result code
if (downloadResponse.getStatus() == HttpStatus.SC_OK) {
workflowStatus.addMessage(serviceName + " successfull");
} else {
workflowStatus.addError(serviceName + " failed with errors");
}
// extract delete
deleteUrl = getUrlFromLink(downloadResponse.getHeaders().get("Link"), "delete", baseUrl);
} else {
String errorUrl = getUrlFromLink(linkHeaders, "error", baseUrl);
if (errorUrl != null) {
workflowStatus.addError(serviceName + " processing finished with error");
// download error file
Path migrationSubDir = storageService.load(workflowStatus.getUuid(), "_" + serviceName.toLowerCase());
Files.createDirectories(migrationSubDir);
HttpResponse<File> errorResponse = Unirest.get(errorUrl)
.asFile(migrationSubDir.resolve("error").toString());
Path migrationError = errorResponse.getBody().toPath();
String filename = getFilenameFromHeader(errorResponse.getHeaders().getFirst("Content-Disposition"));
migrationError = Files.move(migrationError, migrationSubDir.resolve(filename));
workflowStatus.addMessage(serviceName + " error result downloaded");
workflowStatus.addResult(
serviceName + " Error",
MvcUriComponentsBuilder.fromMethodName(
WorkflowController.class,
"serveFile",
workflowStatus.getUuid(),
"_" + serviceName.toLowerCase(),
migrationError.getFileName().toString()).build().toUri().toString());
// extract delete
deleteUrl = getUrlFromLink(errorResponse.getHeaders().get("Link"), "delete", baseUrl);
}
}
if (deleteUrl != null) {
// delete upload again
Unirest.delete(deleteUrl).asEmpty();
workflowStatus.addMessage(serviceName + " cleaned up");
}
} else {
// no status URL in the upload response, stop further processing
workflowStatus.addMessage("No status URL found for service " + serviceName);
return inputFile;
}
return result;
} catch (StorageException | IOException e) {
throw new ProcessingFailedException("Error on file operations in converter workflow", e);
}
}
@ExceptionHandler(StorageFileNotFoundException.class)
public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {
return ResponseEntity.notFound().build();
}
@ModelAttribute("workflowStatus")
public WorkflowStatus workflowStatus() {
return new WorkflowStatus();
}
@ModelAttribute("cloudServiceDefinitions")
public List<CloudServiceDefinition> cloudServiceDefinitions() {
return cloudServiceDefinitions;
}
private static final String LINK_DELIMITER = ",";
private static final String LINK_PARAM_DELIMITER = ";";
/**
* Extracts the url from the link header and returns the url that has the
* specified rel attribute set.
*
* @param linkHeaders The link headers to parse.
* @param rel The value for the rel param for which the url is
* requested.
* @param baseUrl The base URL of the service.
* @return The url for the specified rel param or <code>null</code> if there is
* no link for the given rel.
*/
public static String getUrlFromLink(List<String> linkHeaders, String rel, String baseUrl) {
for (String linkHeader : linkHeaders) {
String[] links = linkHeader.split(LINK_DELIMITER);
for (String link : links) {
String[] segments = link.split(LINK_PARAM_DELIMITER);
if (segments.length < 2) {
continue;
}
String url = segments[0].trim();
if (!url.startsWith("<") || !url.endsWith(">")) {
continue;
}
url = url.substring(1, url.length() - 1);
for (int i = 1; i < segments.length; i++) {
String[] param = segments[i].trim().split("=");
if (param.length < 2 || !"rel".equals(param[0])) {
continue;
}
String relValue = param[1];
if (relValue.startsWith("\"") && relValue.endsWith("\"")) {
relValue = relValue.substring(1, relValue.length() - 1);
}
if (rel.equals(relValue)) {
// SECURITY: ensure that host in link matches host in configured service
if (!url.startsWith(baseUrl)) {
throw new ProcessingFailedException(
"The link for rel '" + rel + "' does not match with the service base URL '" + baseUrl + "': " + url);
}
return url;
}
}
}
}
return null;
}
/**
* Extracts the filename from the Content-Disposition header.
*
* @param header The Content-Disposition header to parse.
* @return The filename from the header or <code>null</code> if no filename
* could be extracted.
*/
public static String getFilenameFromHeader(String header) {
String[] segments = header.split(LINK_PARAM_DELIMITER);
for (String segment : segments) {
if (segment.trim().startsWith("filename=")) {
String filename = segment.substring(segment.indexOf('=') + 1);
if (filename.startsWith("\"") && filename.endsWith("\"")) {
filename = filename.substring(1, filename.length() - 1);
}
return filename;
}
}
return null;
}
}