Updated REST API design

Change-Id: Ic83bca61dcebfe8daf930f1ff109450419047a12
Signed-off-by: Fauth Dirk <Dirk.Fauth@de.bosch.com>
diff --git a/docker-clear.bat b/docker-clear.bat
new file mode 100644
index 0000000..9027a47
--- /dev/null
+++ b/docker-clear.bat
@@ -0,0 +1,3 @@
+@echo off
+FOR /f "tokens=*" %%i IN ('docker ps -aq') DO docker rm %%i
+FOR /f "tokens=*" %%i IN ('docker images --format "{{.ID}}"') DO docker rmi %%i
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c214311
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+version: "3.8"
+services:
+  converter:
+    build:
+      context: ./org.eclipse.app4mc.converter.cloud
+    container_name: app4mc_cloud_converter
+    ports:
+      - "8080:8080"
+    
+  validation:
+    build:
+      context: ./org.eclipse.app4mc.validation.cloud
+    container_name: app4mc_cloud_validation
+    ports:
+      - "8181:8181"
+    
+  manager:
+    build:
+      context: ./manager
+    container_name: app4mc_cloud_manager
+    ports:
+      - "9090:9090"
+      
\ No newline at end of file
diff --git a/manager/Dockerfile b/manager/Dockerfile
new file mode 100644
index 0000000..c980683
--- /dev/null
+++ b/manager/Dockerfile
@@ -0,0 +1,5 @@
+FROM amazoncorretto:8u265
+
+COPY target/manager-0.0.1-SNAPSHOT.jar app.jar
+
+ENTRYPOINT ["java","-jar","/app.jar"]
\ No newline at end of file
diff --git a/manager/pom.xml b/manager/pom.xml
index f426dcb..551fab8 100644
--- a/manager/pom.xml
+++ b/manager/pom.xml
@@ -11,7 +11,6 @@
 	<groupId>org.eclipse.app4mc.cloud</groupId>
 	<artifactId>manager</artifactId>
 	<version>0.0.1-SNAPSHOT</version>
-	<packaging>war</packaging>
 	<name>APP4MC Cloud Manager</name>
 	<description>Manager application to orchestrate AMALTHEA model processing in the cloud</description>
 
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java
index f6a257c..e1fcd9e 100644
--- a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java
@@ -13,11 +13,15 @@
  */
 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.Arrays;
+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;
@@ -47,10 +51,9 @@
 
 	private final StorageService storageService;
 
-	// TODO make url configurable
-	private String CONVERTER_SERVICE_BASE_URL = "http://localhost:8080/app4mc/converter/";
-	private String VALIDATION_SERVICE_BASE_URL = "http://localhost:8181/app4mc/validation/";
-	
+	@javax.annotation.Resource(name = "cloudServiceDefinitions")
+	List<CloudServiceDefinition> cloudServiceDefinitions;
+
 	@Autowired
 	public WorkflowController(StorageService storageService) {
 		this.storageService = storageService;
@@ -59,7 +62,26 @@
 	@GetMapping("/workflow")
 	public String workflow(Model model) {
 		
-		model.addAttribute("allProfiles", Unirest.get(VALIDATION_SERVICE_BASE_URL + "profiles").asJson().getBody().getArray().toList());
+		// 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";
@@ -68,8 +90,8 @@
 	@PostMapping("/workflow")
 	public String handleFileUpload(
 			@RequestParam("file") MultipartFile file, 
-			@RequestParam("services") String[] services, 
-			@RequestParam("profiles") String[] validationProfiles, 
+			@RequestParam(name = "services", required = false) String[] services, 
+			@RequestParam(name = "profiles", required = false) String[] validationProfiles, 
 			Model model, 
 			@ModelAttribute WorkflowStatus ws) {
 		
@@ -92,21 +114,20 @@
 		workflowStatus.addMessage(file.getOriginalFilename() + " successfully uploaded!");
 		model.addAttribute("workflowStatus", workflowStatus);
 		
-		// TODO add proper error handling
-		Path uploaded = storageService.load(uuid, file.getOriginalFilename());
-		try {
-			Path inputFile = uploaded;
-			
-			if (Arrays.stream(services).anyMatch(s -> "Migration".equals(s))) {
-				inputFile = executeConversion(workflowStatus, inputFile, file.getOriginalFilename());
-			}
-			
-			if (Arrays.stream(services).anyMatch(s -> "Validation".equals(s))) {
-				executeValidation(workflowStatus, inputFile, file.getOriginalFilename(), validationProfiles);
-			}
+		if (services != null) {
+			Path inputFile = storageService.load(uuid, file.getOriginalFilename());
+			for (String service : services) {
+				// if an error occurred stop the workflow
+				if (!workflowStatus.getErrors().isEmpty()) {
+					break;
+				}
 
-		} catch (ProcessingFailedException e) {
-			workflowStatus.addError(e.getMessage());
+				try {
+					inputFile = executeCloudService(service, workflowStatus, inputFile, file.getOriginalFilename());
+				} catch (ProcessingFailedException e) {
+					workflowStatus.addError(e.getMessage());
+				}
+			}
 		}
 		
 		return "redirect:/workflow";
@@ -138,99 +159,166 @@
 		return "redirect:/workflow";
 	}
 	
-	private Path executeConversion(WorkflowStatus workflowStatus, Path inputFile, String originalFilename) {
+	private Path executeCloudService(String serviceName, WorkflowStatus workflowStatus, Path inputFile, String originalFilename) {
 		try {
-			// upload to converter service
-			String converterUuid = Unirest.post(CONVERTER_SERVICE_BASE_URL + "upload")
+			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)
-					.asString()
-					.getBody();
+					.asEmpty();
 			
-			workflowStatus.addMessage("Upload to migration service succeeded");
+			// extract status link from result
+			String statusUrl = null;
+			if (uploadResponse.getStatus() == 201) {
+				statusUrl = uploadResponse.getHeaders().getFirst("Location");
+			} 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");
+			} else {
+				// error
+				workflowStatus.addError("Upload to " + serviceName + " failed! Workflow stopped!");
+				return null;
+			}
 			
-			// trigger conversion
-			Unirest.put(CONVERTER_SERVICE_BASE_URL + converterUuid + "/convert").asEmpty();
+			workflowStatus.addMessage("Upload to " + serviceName + " service succeeded");
 			
-			workflowStatus.addMessage("Migration done");
+			Path result = inputFile;
 			
-			// download file
-			Path migrationSubDir = storageService.load(workflowStatus.getUuid(), "_migration");
-			Files.createDirectories(migrationSubDir);
-			Path migrationResult = Unirest.get(CONVERTER_SERVICE_BASE_URL + converterUuid + "/download")
-					.asFile(migrationSubDir.resolve(originalFilename).toString())
-					.getBody()
-					.toPath();
+			// 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");
+				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");
+				} else {
+					String errorUrl = getUrlFromLink(linkHeaders, "error");
+					if (errorUrl != null) {
+						workflowStatus.addMessage(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");
+					}
+				}
+
+				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;
+			}
 			
-			workflowStatus.addMessage("Migrated file downloaded");
-			workflowStatus.addResult(
-					"Migration Result",
-					MvcUriComponentsBuilder.fromMethodName(
-							WorkflowController.class,
-							"serveFile", 
-							workflowStatus.getUuid(),
-							"_migration",
-							migrationResult.getFileName().toString()).build().toUri().toString());
-			
-			// delete converter upload again
-			Unirest.delete(CONVERTER_SERVICE_BASE_URL + converterUuid + "/delete").asEmpty();
-			
-			workflowStatus.addMessage("Migration service cleaned up");
-			
-			return migrationResult;
+			return result;
 
 		} catch (StorageException | IOException e) {
 			throw new ProcessingFailedException("Error on file operations in converter workflow", e);
 		}
 	}
 	
-	private void executeValidation(WorkflowStatus workflowStatus, Path inputFile, String originalFilename, String[] validationProfiles) {
-		try {
-			// upload to validation service
-			String validationUuid = Unirest.post(VALIDATION_SERVICE_BASE_URL + "upload")
-					.field("file", Files.newInputStream(inputFile), originalFilename)
-					.field("profiles", Arrays.asList(validationProfiles))
-					.asString()
-					.getBody();
-			
-			workflowStatus.addMessage("Upload to validation service succeeded");
-			
-			// trigger conversion
-			HttpResponse<?> httpResponse = Unirest.put(VALIDATION_SERVICE_BASE_URL + validationUuid + "/validate").asEmpty();
-			
-			// download file
-			Path validationSubDir = storageService.load(workflowStatus.getUuid(), "_validation");
-			Files.createDirectories(validationSubDir);
-			Path validationResult = Unirest.get(VALIDATION_SERVICE_BASE_URL + validationUuid + "/download")
-					.asFile(validationSubDir.resolve("validation_result.txt").toString())
-					.getBody()
-					.toPath();
-			
-			workflowStatus.addMessage("Validation result downloaded");
-			workflowStatus.addResult(
-					"Validation Result", 
-					MvcUriComponentsBuilder.fromMethodName(
-							WorkflowController.class,
-							"serveFile", 
-							workflowStatus.getUuid(),
-							"_validation",
-							validationResult.getFileName().toString()).build().toUri().toString());
-			
-			// delete validation upload again
-			Unirest.delete(VALIDATION_SERVICE_BASE_URL + validationUuid + "/delete").asEmpty();
-			
-			workflowStatus.addMessage("Validation service cleaned up");
-			
-			if (httpResponse.getStatus() == 200) {
-				workflowStatus.addMessage("Validation successfull");
-			} else {
-				throw new ProcessingFailedException("Validation failed with errors");
-			}
-			
-		} catch (StorageException | IOException e) {
-			throw new ProcessingFailedException("Error on file operations in validation workflow", e);
-		}
-	}
-	
 	@ExceptionHandler(StorageFileNotFoundException.class)
 	public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {
 		return ResponseEntity.notFound().build();
@@ -240,4 +328,84 @@
 	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.
+	 * @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) {
+		
+		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)) {
+						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;
+	}
+
 }
\ No newline at end of file
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/AdminController.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/AdminController.java
new file mode 100644
index 0000000..c86570c
--- /dev/null
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/AdminController.java
@@ -0,0 +1,60 @@
+/*********************************************************************************
+ * 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.administration;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.annotation.Resource;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+
+@Controller
+public class AdminController {
+
+	@Resource(name = "cloudServiceDefinitions")
+	List<CloudServiceDefinition> cloudServiceDefinitions;
+	
+	@GetMapping("/admin")
+    public String adminRequest(final Model model) {
+		CloudServiceDefinitionDTO dto = new CloudServiceDefinitionDTO(cloudServiceDefinitions);
+		dto.addService(new CloudServiceDefinition());
+		
+		model.addAttribute("dto", dto);
+		
+        return "admin";
+    }
+	
+	@PostMapping("/admin/save")
+	public String saveBooks(@ModelAttribute CloudServiceDefinitionDTO dto, Model model) {
+		// TODO save to DB
+		cloudServiceDefinitions.clear();
+		cloudServiceDefinitions.addAll(dto.getServices().stream()
+				.filter(csd -> !StringUtils.isEmpty(csd.getName()) && !StringUtils.isEmpty(csd.getBaseUrl()))
+				.collect(Collectors.toList()));
+	 
+	    model.addAttribute("dto", new CloudServiceDefinitionDTO(cloudServiceDefinitions));
+	    return "redirect:/admin";
+	}
+	
+	@ModelAttribute("cloudServiceDefinitions")
+	public List<CloudServiceDefinition> cloudServiceDefinitions() {
+		return cloudServiceDefinitions;
+	}
+}
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/ApplicationConfig.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/ApplicationConfig.java
new file mode 100644
index 0000000..c69243a
--- /dev/null
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/ApplicationConfig.java
@@ -0,0 +1,21 @@
+package org.eclipse.app4mc.cloud.manager.administration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.context.annotation.ApplicationScope;
+
+@Configuration
+public class ApplicationConfig {
+
+	@Bean
+    @ApplicationScope
+    public List<CloudServiceDefinition> cloudServiceDefinitions() {
+        ArrayList<CloudServiceDefinition> definitions = new ArrayList<>();
+        definitions.add(new CloudServiceDefinition("Migration", "http://localhost:8080/app4mc/converter/", "Model Migration Service"));
+        definitions.add(new CloudServiceDefinition("Validation", "http://localhost:8181/app4mc/validation/", "Model Validation Service"));
+        return definitions;
+    }
+}
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/CloudServiceDefinition.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/CloudServiceDefinition.java
new file mode 100644
index 0000000..f1069bc
--- /dev/null
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/CloudServiceDefinition.java
@@ -0,0 +1,43 @@
+package org.eclipse.app4mc.cloud.manager.administration;
+
+public class CloudServiceDefinition {
+
+	String name;
+	String baseUrl;
+	String description;
+	
+	public CloudServiceDefinition() {
+		// empty default constructor
+	}
+	
+	public CloudServiceDefinition(String name, String baseUrl, String description) {
+		this.name = name;
+		this.baseUrl = baseUrl;
+		this.description = description;
+	}
+
+	public String getName() {
+		return name;
+	}
+	
+	public void setName(String name) {
+		this.name = name;
+	}
+	
+	public String getBaseUrl() {
+		return baseUrl;
+	}
+	
+	public void setBaseUrl(String baseUrl) {
+		this.baseUrl = baseUrl;
+	}
+	
+	public String getDescription() {
+		return description;
+	}
+	
+	public void setDescription(String description) {
+		this.description = description;
+	}
+	
+}
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/CloudServiceDefinitionDTO.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/CloudServiceDefinitionDTO.java
new file mode 100644
index 0000000..5f71883
--- /dev/null
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/administration/CloudServiceDefinitionDTO.java
@@ -0,0 +1,29 @@
+package org.eclipse.app4mc.cloud.manager.administration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CloudServiceDefinitionDTO {
+
+	private ArrayList<CloudServiceDefinition> services = new ArrayList<>();
+	
+	public CloudServiceDefinitionDTO(List<CloudServiceDefinition> services) {
+		if (services != null) {
+			this.services = new ArrayList<CloudServiceDefinition>(services);
+		} else {
+			this.services = new ArrayList<>();
+		}
+	}
+
+	public ArrayList<CloudServiceDefinition> getServices() {
+		return services;
+	}
+
+	public void setServices(ArrayList<CloudServiceDefinition> services) {
+		this.services = services;
+	}
+
+	public void addService(CloudServiceDefinition service) {
+		this.services.add(service);
+	}
+}
diff --git a/manager/src/main/resources/static/index.html b/manager/src/main/resources/static/index.html
index eb1604f..be3776e 100644
--- a/manager/src/main/resources/static/index.html
+++ b/manager/src/main/resources/static/index.html
@@ -7,5 +7,6 @@
 <body>
 	<h1>APP4MC Cloud Manager</h1>
     <p><a href="/workflow">Start Workflow</a></p>
+    <p><a href="/admin">Administration</a></p>
 </body>
 </html>
diff --git a/manager/src/main/resources/templates/admin.html b/manager/src/main/resources/templates/admin.html
new file mode 100644
index 0000000..323059e
--- /dev/null
+++ b/manager/src/main/resources/templates/admin.html
@@ -0,0 +1,37 @@
+<html xmlns:th="https://www.thymeleaf.org">
+<head> 
+    <title>APP4MC Cloud Manager - Administration</title> 
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+</head>
+<body>
+
+	<div>
+		<h2>Configure available APP4MC Cloud services</h2>
+	</div>
+	<div>
+		<form action="#" th:action="@{/admin/save}" th:object="${dto}" method="POST">
+		    <fieldset>
+		        <input type="submit" id="submitButton" th:value="Save">
+		        <input type="reset" id="resetButton" name="reset" th:value="Reset"/>
+		        <table>
+		            <thead>
+		                <tr>
+		                    <th>Service Name</th>
+		                    <th>Service Base URL</th>
+		                    <th>Service Description</th>
+		                </tr>
+		            </thead>
+		            <tbody>
+		                <tr th:each="service, stat : *{services}">
+		                    <td><input th:field="*{services[__${stat.index}__].name}" /></td>
+		                    <td><input th:field="*{services[__${stat.index}__].baseUrl}" /></td>
+		                    <td><input th:field="*{services[__${stat.index}__].description}" /></td>
+		                </tr>
+		            </tbody>
+		        </table>
+		    </fieldset>
+		</form>
+	</div>
+
+</body>
+</html>
\ No newline at end of file
diff --git a/manager/src/main/resources/templates/workflow.html b/manager/src/main/resources/templates/workflow.html
index b59c211..06c5df3 100644
--- a/manager/src/main/resources/templates/workflow.html
+++ b/manager/src/main/resources/templates/workflow.html
@@ -12,8 +12,9 @@
 				<tr>
 					<td>Select service(s) to process:</td>
 					<td>
-						<input type="checkbox" name="services" value="Migration" />Migration<br>
-						<input type="checkbox" name="services" value="Validation" />Validation<br>
+						<span th:each="service : ${cloudServiceDefinitions}">
+							<input type="checkbox" name="services" th:text="${service.name}" th:value="${service.name}" /><br>
+						</span>
 					</td>
 				</tr>
 				<tr>
diff --git a/org.eclipse.app4mc.converter.cloud/Dockerfile b/org.eclipse.app4mc.converter.cloud/Dockerfile
new file mode 100644
index 0000000..58ec81d
--- /dev/null
+++ b/org.eclipse.app4mc.converter.cloud/Dockerfile
@@ -0,0 +1,5 @@
+FROM amazoncorretto:8u265
+
+COPY converter-app/target/converter-app.jar converter-app.jar
+
+ENTRYPOINT ["java","-jar","/converter-app.jar"]
\ No newline at end of file
diff --git a/org.eclipse.app4mc.converter.cloud/converter-app/converter-app.bndrun b/org.eclipse.app4mc.converter.cloud/converter-app/converter-app.bndrun
index a92513f..d849fba 100644
--- a/org.eclipse.app4mc.converter.cloud/converter-app/converter-app.bndrun
+++ b/org.eclipse.app4mc.converter.cloud/converter-app/converter-app.bndrun
@@ -19,7 +19,8 @@
 	bnd.identity;id='org.eclipse.app4mc.amalthea.converters.095',\
 	bnd.identity;id='org.eclipse.app4mc.amalthea.converters.096',\
 	bnd.identity;id='org.eclipse.app4mc.amalthea.converters.097',\
-	osgi.identity;filter:='(&(osgi.identity=jaxen)(version>=1.1.6))'
+	osgi.identity;filter:='(&(osgi.identity=jaxen)(version>=1.1.6))',\
+	osgi.identity;filter:='(&(osgi.identity=org.apache.aries.jax.rs.jackson)(version>=1.0.2))'
 -runfw: org.eclipse.osgi
 -runee: JavaSE-1.8
 
@@ -30,8 +31,15 @@
 -runbundles: \
 	ch.qos.logback.classic;version='[1.2.3,1.2.4)',\
 	ch.qos.logback.core;version='[1.2.3,1.2.4)',\
+	com.fasterxml.jackson.core.jackson-annotations;version='[2.11.2,2.11.3)',\
+	com.fasterxml.jackson.core.jackson-core;version='[2.11.2,2.11.3)',\
+	com.fasterxml.jackson.core.jackson-databind;version='[2.11.2,2.11.3)',\
+	com.fasterxml.jackson.jaxrs.jackson-jaxrs-base;version='[2.9.6,2.9.7)',\
+	com.fasterxml.jackson.jaxrs.jackson-jaxrs-json-provider;version='[2.9.6,2.9.7)',\
+	com.fasterxml.jackson.module.jackson-module-jaxb-annotations;version='[2.9.6,2.9.7)',\
 	jaxen;version='[1.1.6,1.1.7)',\
 	org.apache.aries.javax.jax.rs-api;version='[1.0.0,1.0.1)',\
+	org.apache.aries.jax.rs.jackson;version='[1.0.2,1.0.3)',\
 	org.apache.aries.jax.rs.whiteboard;version='[1.0.1,1.0.2)',\
 	org.apache.felix.configadmin;version='[1.9.8,1.9.9)',\
 	org.apache.felix.configurator;version='[1.0.6,1.0.7)',\
@@ -40,23 +48,23 @@
 	org.apache.felix.scr;version='[2.1.10,2.1.11)',\
 	org.apache.servicemix.bundles.jdom;version='[2.0.6,2.0.7)',\
 	org.apache.servicemix.specs.annotation-api-1.3;version='[1.3.0,1.3.1)',\
-	org.eclipse.app4mc.amalthea.converters.071;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.072;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.080;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.081;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.082;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.083;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.090;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.091;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.092;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.093;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.094;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.095;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.096;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.097;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.amalthea.converters.common;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.converter-app;version='[0.9.9,0.9.10)',\
-	org.eclipse.app4mc.converter-service;version='[0.9.9,0.9.10)',\
+	org.eclipse.app4mc.amalthea.converters.071;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.072;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.080;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.081;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.082;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.083;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.090;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.091;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.092;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.093;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.094;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.095;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.096;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.097;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.amalthea.converters.common;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.converter-app;version='[1.0.0,1.0.1)',\
+	org.eclipse.app4mc.converter-service;version='[1.0.0,1.0.1)',\
 	org.eclipse.equinox.common;version='[3.12.0,3.12.1)',\
 	org.eclipse.osgi.services;version='[3.8.0,3.8.1)',\
 	org.eclipse.osgi.util;version='[3.5.300,3.5.301)',\
diff --git a/org.eclipse.app4mc.converter.cloud/converter-app/pom.xml b/org.eclipse.app4mc.converter.cloud/converter-app/pom.xml
index ec7faaa..e045039 100644
--- a/org.eclipse.app4mc.converter.cloud/converter-app/pom.xml
+++ b/org.eclipse.app4mc.converter.cloud/converter-app/pom.xml
@@ -4,7 +4,7 @@
     <parent>
         <groupId>org.eclipse.app4mc</groupId>
         <artifactId>org.eclipse.app4mc.converter.cloud</artifactId>
-        <version>0.9.9-SNAPSHOT</version>
+        <version>1.0.0-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
@@ -16,7 +16,7 @@
         <dependency>
             <groupId>org.eclipse.app4mc</groupId>
             <artifactId>converter-service</artifactId>
-            <version>0.9.9-SNAPSHOT</version>
+            <version>1.0.0-SNAPSHOT</version>
         </dependency>
         <dependency>
             <groupId>org.osgi.enroute</groupId>
diff --git a/org.eclipse.app4mc.converter.cloud/converter-service/pom.xml b/org.eclipse.app4mc.converter.cloud/converter-service/pom.xml
index 34762ee..0add9b1 100644
--- a/org.eclipse.app4mc.converter.cloud/converter-service/pom.xml
+++ b/org.eclipse.app4mc.converter.cloud/converter-service/pom.xml
@@ -49,6 +49,26 @@
 				pom
 			</type>
 		</dependency>
+		<dependency>
+		    <groupId>com.fasterxml.jackson.core</groupId>
+		    <artifactId>jackson-annotations</artifactId>
+		    <version>2.11.2</version>
+		</dependency>
+		<dependency>
+		    <groupId>com.fasterxml.jackson.core</groupId>
+		    <artifactId>jackson-core</artifactId>
+		    <version>2.11.2</version>
+		</dependency>
+		<dependency>
+		    <groupId>com.fasterxml.jackson.core</groupId>
+		    <artifactId>jackson-databind</artifactId>
+		    <version>2.11.2</version>
+		</dependency>
+		<dependency>
+		    <groupId>org.apache.aries.jax.rs</groupId>
+		    <artifactId>org.apache.aries.jax.rs.jackson</artifactId>
+		    <version>1.0.2</version>
+		</dependency>
 		<!-- APP4MC Migration -->
 		<dependency>
 			<artifactId>
@@ -300,12 +320,12 @@
 			../pom.xml
 		</relativePath>
 		<version>
-			0.9.9-SNAPSHOT
+			1.0.0-SNAPSHOT
 		</version>
 	</parent>
 	<properties>
 		<app4mc.version>
-			0.9.9-SNAPSHOT
+			1.0.0-SNAPSHOT
 		</app4mc.version>
 	</properties>
 </project>
diff --git a/org.eclipse.app4mc.converter.cloud/converter-service/src/main/java/org/eclipse/app4mc/org/eclipse/app4mc/converter/cloud/MigrationRestService.java b/org.eclipse.app4mc.converter.cloud/converter-service/src/main/java/org/eclipse/app4mc/org/eclipse/app4mc/converter/cloud/MigrationRestService.java
index d909002..6800b89 100644
--- a/org.eclipse.app4mc.converter.cloud/converter-service/src/main/java/org/eclipse/app4mc/org/eclipse/app4mc/converter/cloud/MigrationRestService.java
+++ b/org.eclipse.app4mc.converter.cloud/converter-service/src/main/java/org/eclipse/app4mc/org/eclipse/app4mc/converter/cloud/MigrationRestService.java
@@ -16,14 +16,20 @@
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.PrintWriter;
 import java.nio.file.Files;
 import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
+import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.Part;
@@ -31,14 +37,18 @@
 import javax.ws.rs.DELETE;
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
-import javax.ws.rs.PUT;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
+import javax.ws.rs.core.CacheControl;
 import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Link;
 import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.PathSegment;
 import javax.ws.rs.core.Response;
-import javax.ws.rs.core.Response.ResponseBuilder;
+import javax.ws.rs.core.Response.Status;
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
 
 import org.eclipse.app4mc.amalthea.converters.common.MigrationException;
 import org.eclipse.app4mc.amalthea.converters.common.MigrationHelper;
@@ -49,12 +59,15 @@
 import org.eclipse.app4mc.amalthea.converters.common.utils.ModelVersion;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.jaxrs.whiteboard.propertytypes.JSONRequired;
 import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Component(service=MigrationRestService.class)
 @JaxrsResource
+@Produces(MediaType.APPLICATION_JSON)
+@JSONRequired
 @Path("app4mc/converter")
 public class MigrationRestService {
 
@@ -62,72 +75,169 @@
 
 	private static final Logger LOGGER = LoggerFactory.getLogger(MigrationRestService.class);
 	
+	private static final String PROGRESS_MARKER = "in_progress";
+	private static final String ERROR_MARKER = "error";
+	private static final String FINISHED_MARKER = "finished";
+	
+	private static final String ERROR_FILE = "error.txt";
+	
 	private final String defaultBaseDir = System.getProperty("java.io.tmpdir");
 	
+	private ExecutorService executor = Executors.newFixedThreadPool(1);
+	
 	@Reference
 	MigrationProcessor migrationProcessor;
 	
-    @Path("upload")
     @POST
     @Consumes(MediaType.MULTIPART_FORM_DATA)
-    @Produces(MediaType.TEXT_HTML)
-    public Response upload(@Context HttpServletRequest request) throws IOException, ServletException {
+    public Response upload(@Context HttpServletRequest request, @Context UriInfo uriInfo, @Context ServletContext context) throws IOException, ServletException {
     	
+    	Link self = Link.fromUriBuilder(uriInfo.getAbsolutePathBuilder()).rel("self").build();
+
     	Part part = request.getPart("file");
-    	if (part != null) {
+    	if (part != null && part.getSubmittedFileName() != null && part.getSubmittedFileName().length() > 0) {
     		String filename = part.getSubmittedFileName();
     		try (InputStream is = part.getInputStream()) {
-    			java.nio.file.Path path = Files.createTempDirectory(TEMP_DIR_PREFIX);
+    			java.nio.file.Path tempFolderPath = Files.createTempDirectory(TEMP_DIR_PREFIX);
     			
     			// extract uuid from pathname
-    			String uuid = path.toString().substring(path.toString().lastIndexOf('_') + 1);
+    			String uuid = tempFolderPath.toString().substring(tempFolderPath.toString().lastIndexOf('_') + 1);
     			
-    			Files.copy(is, Paths.get(path.toString(), filename));
+    			// copy file to temporary location
+    			java.nio.file.Path uploaded = Paths.get(tempFolderPath.toString(), filename);
+    			Files.copy(is, uploaded);
     			
-    			// return uuid
-    			return Response.ok(uuid).build();
+    			if (Files.exists(uploaded)) {
+    				// mark uuid in progress
+    				getRegistry(context).put(uuid, PROGRESS_MARKER);
+    				
+    				// trigger asynchronous processing
+    				executor.execute(() -> {
+    					
+    					String outputModelVersion = ModelVersion.getLatestVersion();
+    					
+    					try (MigrationSettings migrationSettings = new MigrationSettings()) {
+    						migrationSettings.setProject(uploaded.getParent().toFile());
+    						migrationSettings.setMigrationModelVersion(outputModelVersion);
+    						
+    						convert(Arrays.asList(uploaded.toFile()), migrationSettings, tempFolderPath, getRegistry(context));
+    					} finally {
+    						if (!ERROR_MARKER.equals(getRegistry(context).get(uuid))) {
+    							getRegistry(context).put(uuid, FINISHED_MARKER);
+    						}
+    					}
+    				});
+    				
+    				// file upload succeeded and processing is triggered
+    				Link statusLink = Link.fromUriBuilder(uriInfo.getAbsolutePathBuilder()
+                            .path(uuid))
+    						.rel("status")
+    						.build();
+    				
+    				return Response
+    						.created(statusLink.getUri())
+    						.entity(uuid)
+    						.links(self, statusLink)
+    						.build();
+    			} else {
+    				// file upload failed
+    				return Response
+    						.status(Status.NOT_FOUND)
+    						.entity("Model file upload failed!")
+    						.links(self)
+    						.build();
+    			}
     		}
     	}
 
-    	return Response.noContent().build();
+    	return Response
+    			.status(Status.BAD_REQUEST)
+    			.entity("No model file provided!")
+    			.links(self)
+    			.build();
     }
     
-    @Path("{uuid}/convert")
-    @PUT
-    public Response convert(@PathParam("uuid") String uuid) throws IOException {
-		
-    	java.nio.file.Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
-    	List<java.nio.file.Path> modelFilePaths = 
-    			Files.find(tempFolderPath, 1, (path, attrs) -> path.toString().endsWith(".amxmi"))
-    			.collect(Collectors.toList());
+    @Path("{uuid}")
+    @GET
+    public Response status(@PathParam("uuid") String uuid, @Context UriInfo uriInfo, @Context ServletContext context) throws IOException {
+    	CacheControl cacheControl = new CacheControl();
+    	cacheControl.setNoCache(true);
+    	cacheControl.setNoStore(true);
     	
-		if (modelFilePaths.isEmpty()) {
+    	Link self = Link.fromUriBuilder(uriInfo.getAbsolutePathBuilder()).rel("self").build();
+    	
+    	java.nio.file.Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
+    	if (!Files.exists(tempFolderPath)) {
 			return Response
-					.status(Response.Status.NOT_FOUND)
-					.entity("No model file uploaded!")
+					.status(Status.NOT_FOUND)
+					.entity("No status resource available for id " + uuid)
 					.build();
-		}
-		
-		// build up MigrationSettings
-		// same as AmaltheaModelMigrationHandler#collectInput
-		
-		String outputModelVersion = ModelVersion.getLatestVersion();
+    	}
+    	
+    	String status = getRegistry(context).get(uuid);
+    	
+    	boolean hasErrorFile = false;
+    	try (Stream<java.nio.file.Path> files = Files.list(tempFolderPath)) {
+    		hasErrorFile = files.anyMatch(path -> path.endsWith(ERROR_FILE));
+    	}
+    	
+    	if (PROGRESS_MARKER.equals(status)) {
+    		return Response
+    				.accepted()
+    				.links(self)
+    				.cacheControl(cacheControl)
+    				.build();
+    	} else if (ERROR_MARKER.equals(status) || hasErrorFile) {
+        	// processing finished with error
+    		Link errorLink = Link.fromUriBuilder(uriInfo.getAbsolutePathBuilder()
+                    .path("error"))
+    				.rel("error")
+    				.build();
 
-		java.nio.file.Path modelFilePath = modelFilePaths.get(0);
-		try (MigrationSettings migrationSettings = new MigrationSettings()) {
-			migrationSettings.setProject(modelFilePath.getParent().toFile());
-			migrationSettings.setMigrationModelVersion(outputModelVersion);
-			
-			// TODO execute in background thread to avoid timeout and provide status resource
-			return convert(Arrays.asList(modelFilePath.toFile()), migrationSettings);
-		}
+    		return Response
+    				.noContent()
+    				.links(self, errorLink)
+    				.cacheControl(cacheControl)
+    				.build();
+    	}
+    	
+    	// processing is finished
+		Link downloadLink = Link.fromUriBuilder(uriInfo.getAbsolutePathBuilder()
+                .path("download"))
+				.rel("result")
+				.build();
+
+		return Response
+				.ok()
+				.links(self, downloadLink)
+				.cacheControl(cacheControl)
+				.build();
     }
 
     @Path("{uuid}/download")
     @GET
-    @Produces(MediaType.APPLICATION_OCTET_STREAM)
-    public Response download(@PathParam("uuid") String uuid) throws IOException {
+    @Produces({ MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_JSON } )
+    public Response download(@PathParam("uuid") String uuid, @Context UriInfo uriInfo, @Context ServletContext context) throws IOException {
+    	Link self = Link.fromUriBuilder(uriInfo.getAbsolutePathBuilder()).rel("self").build();
+
     	java.nio.file.Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
+    	if (!Files.exists(tempFolderPath)) {
+			return Response
+					.status(Status.NOT_FOUND)
+					.entity("No download resource available for id " + uuid)
+					.build();
+    	}
+
+    	// if process is in progress, the download resource is 404
+    	String status = getRegistry(context).get(uuid);
+    	
+    	if (PROGRESS_MARKER.equals(status)) {
+    		return Response
+    				.status(Status.NOT_FOUND)
+    				.entity("Process is still in progresss")
+    				.links(self)
+    				.build();
+    	}
     	
     	List<java.nio.file.Path> modelFilePaths = 
     			Files.find(tempFolderPath, 1, (path, attrs) -> path.toString().endsWith(".amxmi"))
@@ -135,28 +245,90 @@
     	
 		if (modelFilePaths.isEmpty()) {
 			return Response
-					.status(Response.Status.NOT_FOUND)
+					.status(Status.NOT_FOUND)
 					.entity("No migrated model file available!")
+    				.links(self)
 					.build();
 		}
 
 		java.nio.file.Path modelFilePath = modelFilePaths.get(0);
     	
-		ResponseBuilder response = Response.ok(modelFilePath.toFile());
-        response.header("Content-Disposition", "attachment; filename=\"" + modelFilePath.toFile().getName() + "\"");
-        return response.build();
+		List<PathSegment> pathSegments = uriInfo.getPathSegments();
+    	UriBuilder uriBuilder = uriInfo.getAbsolutePathBuilder().replacePath("");
+    	for (int i = 0; i < pathSegments.size() - 1; i++) {
+    		uriBuilder.path(pathSegments.get(i).getPath());
+    	}
+		Link deleteLink = Link.fromUriBuilder(uriBuilder)
+				.rel("delete")
+				.build();
+
+		return Response
+				.ok(modelFilePath.toFile())
+				.header("Content-Disposition", "attachment; filename=\"" + modelFilePath.toFile().getName() + "\"")
+				.links(self, deleteLink)
+				.build();
     }
     
-    @Path("{uuid}/delete")
+    @Path("{uuid}/error")
+    @GET
+    @Produces({ MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_JSON } )
+    public Response error(@PathParam("uuid") String uuid, @Context UriInfo uriInfo) throws IOException {
+    	Link self = Link.fromUriBuilder(uriInfo.getAbsolutePathBuilder()).rel("self").build();
+
+    	java.nio.file.Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
+    	
+    	if (!Files.exists(tempFolderPath)) {
+    		return Response.status(Status.NOT_FOUND).build();
+    	}
+    	
+    	boolean hasErrorFile = false;
+    	try (Stream<java.nio.file.Path> files = Files.list(tempFolderPath)) {
+    		hasErrorFile = files.anyMatch(path -> path.endsWith(ERROR_FILE));
+    	}
+
+    	// if there is no error file, the error resource is 404
+    	if (!hasErrorFile) {
+    		return Response
+    				.status(Status.NOT_FOUND)
+    				.entity("No error occured")
+    				.links(self)
+    				.build();
+    	}
+    	
+    	java.nio.file.Path errorFilePath = Paths.get(tempFolderPath.toString(), ERROR_FILE);
+    	
+		List<PathSegment> pathSegments = uriInfo.getPathSegments();
+    	UriBuilder uriBuilder = uriInfo.getAbsolutePathBuilder().replacePath("");
+    	for (int i = 0; i < pathSegments.size() - 1; i++) {
+    		uriBuilder.path(pathSegments.get(i).getPath());
+    	}
+		Link deleteLink = Link.fromUriBuilder(uriBuilder)
+				.rel("delete")
+				.build();
+
+		return Response
+				.ok(errorFilePath.toFile())
+				.header("Content-Disposition", "attachment; filename=\"" + errorFilePath.toFile().getName() + "\"")
+				.links(self, deleteLink)
+				.build();
+    }
+    
+    @Path("{uuid}")
     @DELETE
-    public Response delete(@PathParam("uuid") String uuid) throws IOException {
+    public Response delete(@PathParam("uuid") String uuid, @Context ServletContext context) throws IOException {
     	java.nio.file.Path path = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
     	
+    	if (!Files.exists(path)) {
+    		return Response.status(Status.NOT_FOUND).build();
+    	}
+    	
     	Files.walk(path)
 	    	.sorted(Comparator.reverseOrder())
 	    	.map(java.nio.file.Path::toFile)
 	    	.forEach(File::delete);
     	
+    	getRegistry(context).remove(uuid);
+    	
     	return Response.ok().build();
     }
 
@@ -165,7 +337,8 @@
      * @param inputFiles
      * @param migrationSettings
      */
-	private Response convert(List<File> inputFiles, MigrationSettings migrationSettings) {
+	private void convert(List<File> inputFiles, MigrationSettings migrationSettings, java.nio.file.Path tempFolderPath, Map<String, String> statusMap) {
+		String uuid = tempFolderPath.toString().substring(tempFolderPath.toString().lastIndexOf('_') + 1);
 
 		// same as ModelLoaderJob
 		try {
@@ -173,10 +346,9 @@
 			migrationSettings.getMigModelFiles().addAll(modelFiles);
 		} catch (Exception e) {
 			LOGGER.error("Failed to load model files", e);
-			return Response
-					.status(Response.Status.BAD_REQUEST)
-					.entity("Failed to load model files")
-					.build();
+			statusMap.put(uuid, ERROR_MARKER);
+			error(tempFolderPath, "Failed to load model files", e);
+			return;
 		}
 
 		// same as AmaltheaModelMigrationHandler#JobChangeListener
@@ -184,10 +356,9 @@
 			boolean inputValid = MigrationHelper.isInputModelVersionValid(migrationSettings);
 			if (!inputValid) {
 				LOGGER.error("Model migration stopped in {} as selected model files belong to different versions", migrationSettings.getOutputDirectoryLocation());
-				return Response
-						.status(Response.Status.BAD_REQUEST)
-						.entity("Model migration stopped as selected model files belong to different versions")
-						.build();
+				statusMap.put(uuid, ERROR_MARKER);
+				error(tempFolderPath, "Model migration stopped as selected model files belong to different versions", null);
+				return;
 			} else {
 
 				if (migrationSettings.getInputModelVersion() != null
@@ -196,10 +367,7 @@
 					LOGGER.error("Selected model is compatible to latest AMALTHEA meta-model version {}.\nIt is not required to migrate the model in {}",
 							ModelVersion.getLatestVersion(), 
 							migrationSettings.getOutputDirectoryLocation());
-					return Response
-							.status(Response.Status.BAD_REQUEST)
-							.entity("Selected model is compatible to latest AMALTHEA meta-model version " + ModelVersion.getLatestVersion() + ".\\nIt is not required to migrate the model.")
-							.build();
+					return;
 				} else {
 					// check if a migration needs to be executed
 					Map<String, String> migStepEntries = MigrationHelper.generateMigrationSteps(
@@ -210,13 +378,14 @@
 						LOGGER.error("Migration not supported for the selected model versions.\nInput Model version : \"{}\" Output Model Version : \"{}\"", 
 								migrationSettings.getInputModelVersion(), 
 								migrationSettings.getMigrationModelVersion());
-						return Response
-								.status(Response.Status.BAD_REQUEST)
-								.entity("Migration not supported for the selected model versions.\nInput Model version : "
-										+ migrationSettings.getInputModelVersion() 
+						statusMap.put(uuid, ERROR_MARKER);
+						error(tempFolderPath,
+								"Migration not supported for the selected model versions.\nInput Model version : "
+										+ migrationSettings.getInputModelVersion()
 										+ " Output Model Version : "
-										+ migrationSettings.getMigrationModelVersion())
-								.build();
+										+ migrationSettings.getMigrationModelVersion(),
+								null);
+						return;
 					}
 					
 					// set the file parent folder as output location to convert the file at source
@@ -235,19 +404,19 @@
 									+ migrationSettings.getInputModelVersion()
 									+ "\" Output Model Version : \""
 									+ migrationSettings.getMigrationModelVersion() + "\"");
-							return Response
-									.status(Response.Status.BAD_REQUEST)
-									.entity("Migration not supported for the selected model versions.\nInput Model version : "
-											+ migrationSettings.getInputModelVersion() 
+							statusMap.put(uuid, ERROR_MARKER);
+							error(tempFolderPath,
+									"Migration not supported for the selected model versions.\nInput Model version : "
+											+ migrationSettings.getInputModelVersion()
 											+ " Output Model Version : "
-											+ migrationSettings.getMigrationModelVersion())
-									.build();
+											+ migrationSettings.getMigrationModelVersion(),
+									null);
+							return;
 						case MigrationStatusCode.ERROR:
 							LOGGER.error("Error during migration in {}", migrationSettings.getOutputDirectoryLocation());
-							return Response
-									.status(Response.Status.BAD_REQUEST)
-									.entity("Error during migration.")
-									.build();
+							statusMap.put(uuid, ERROR_MARKER);
+							error(tempFolderPath, "Error during migration.", null);
+							return;
 						default:
 							LOGGER.info("Model Migration in {} successful !!", migrationSettings.getOutputDirectoryLocation());
 					}
@@ -255,13 +424,31 @@
 			}
 		} catch (MigrationException e) {
 			LOGGER.error("Error during migration in {} : {}", migrationSettings.getOutputDirectoryLocation(), e.getLocalizedMessage());
-			return Response
-					.status(Response.Status.BAD_REQUEST)
-					.entity("Error during migration. " + e.getLocalizedMessage())
-					.build();
+			statusMap.put(uuid, ERROR_MARKER);
+			error(tempFolderPath, "Error during migration.", e);
 		}
-
-		return Response.ok("Model Migration successful!").build();
 	}
 
+	private void error(java.nio.file.Path resultFolder, String message, Exception exception) {
+		try {
+			java.nio.file.Path errorFilePath = Files.createFile(Paths.get(resultFolder.toString(), ERROR_FILE));
+			try (PrintWriter writer = new PrintWriter(Files.newOutputStream(errorFilePath))) {
+				writer.append(message).append(System.lineSeparator());
+				if (exception != null) {
+					exception.printStackTrace(writer);
+				}
+			}
+			
+		} catch (IOException e) {
+			LOGGER.error("Failed to write error.txt", e);
+		}
+	}
+	
+	@SuppressWarnings("unchecked")
+	private static synchronized Map<String, String> getRegistry(ServletContext context) {
+			if (context.getAttribute("_REGISTRY") == null) {
+				context.setAttribute("_REGISTRY", new ConcurrentHashMap<String, String>());
+			}
+			return (Map<String, String>) context.getAttribute("_REGISTRY");
+	}
 }
diff --git a/org.eclipse.app4mc.converter.cloud/openapi.yaml b/org.eclipse.app4mc.converter.cloud/openapi.yaml
new file mode 100644
index 0000000..78d0491
--- /dev/null
+++ b/org.eclipse.app4mc.converter.cloud/openapi.yaml
@@ -0,0 +1,174 @@
+openapi: 3.0.0
+info:
+  version: 0.9.9
+  title: APP4MC Migration API
+  description: APP4MC Model Migration API to migrate older model versions to the current one
+
+servers:
+  - url: http://localhost:8080/app4mc/converter
+  
+paths:
+  /:
+    post:
+      summary: Upload the file to migrate and start the migration process asynchronously
+      requestBody:
+        content:
+          multipart/form-data:
+            schema:
+              type: object
+              properties:
+                file:
+                  type: string
+                  format: binary
+      responses:
+        '201':
+          description: Upload succeeded and migration process started
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id:
+                    type: string
+                    description: ID of the created migration resource
+          headers:
+            Location:
+              schema:
+                type: string
+                format: uri
+              description: The URI to the status URL
+          links:
+            status:
+              operationId: getStatus
+              parameters:
+                statusId: '$response.body#/id'
+              description: >
+                The `id` value returned in the response can be used as
+                the `statusId` parameter in `GET /{statusId}`.
+        '400':
+          description: No model file provided as upload
+        '404':
+          description: Upload failed
+
+  /{statusId}:
+    get:
+      summary: Get the status of the triggered migration process
+      operationId: getStatus
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Processing finished successfully
+          headers:
+            Cache-Control:
+              schema:
+                type: string
+                enum:
+                  - private, no-store, no-cache, must-revalidate
+          links:
+            result:
+              operationId: getDownload
+              parameters:
+                statusId: $request.path.statusId
+        '202':
+          description: Migration process in progress
+          headers:
+            Cache-Control:
+              schema:
+                type: string
+                enum:
+                  - private, no-store, no-cache, must-revalidate
+        '204':
+          description: Processing finished with an error
+          headers:
+            Cache-Control:
+              schema:
+                type: string
+                enum:
+                  - private, no-store, no-cache, must-revalidate
+          links:
+            result:
+              operationId: getError
+              parameters:
+                statusId: $request.path.statusId
+        '404':
+          description: Resource not available
+
+    delete:
+      summary: Delete the uploaded and transformed resource from the server
+      operationId: deleteResource
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Resource deleted successfully
+        '404':
+          description: Resource not available
+
+  /{statusId}/download:
+    get:
+      summary: Download the migrated model file
+      operationId: getDownload
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Migration result available
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+          links:
+            delete:
+              operationId: deleteResource
+              parameters:
+                statusId: $request.path.statusId
+        '404':
+          description: Progress still running / Resource not available / No result available
+          content:
+            application/json:
+              schema:
+                type: string
+
+  /{statusId}/error:
+    get:
+      summary: Download the error file of the migration process
+      operationId: getError
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Error occured
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+          links:
+            delete:
+              operationId: deleteResource
+              parameters:
+                statusId: $request.path.statusId
+        '404':
+          description: Resource not available / No error occured
+          content:
+            application/json:
+              schema:
+                type: string
diff --git a/org.eclipse.app4mc.converter.cloud/pom.xml b/org.eclipse.app4mc.converter.cloud/pom.xml
index 6082ec2..d8de7da 100644
--- a/org.eclipse.app4mc.converter.cloud/pom.xml
+++ b/org.eclipse.app4mc.converter.cloud/pom.xml
@@ -7,7 +7,7 @@
 		org.eclipse.app4mc.converter.cloud
 	</artifactId>
 	<version>
-		0.9.9-SNAPSHOT
+		1.0.0-SNAPSHOT
 	</version>
 	<packaging>
 		pom
diff --git a/org.eclipse.app4mc.validation.cloud/Dockerfile b/org.eclipse.app4mc.validation.cloud/Dockerfile
new file mode 100644
index 0000000..6b9b08f
--- /dev/null
+++ b/org.eclipse.app4mc.validation.cloud/Dockerfile
@@ -0,0 +1,6 @@
+FROM amazoncorretto:8u265
+
+ADD org.eclipse.app4mc.validation.cloud.product/target/products/org.eclipse.app4mc.validation.cloud.product-linux.gtk.x86_64.tar.gz org.eclipse.app4mc.validation.cloud.product
+RUN chmod +x org.eclipse.app4mc.validation.cloud.product/eclipse
+
+ENTRYPOINT org.eclipse.app4mc.validation.cloud.product/eclipse
\ No newline at end of file
diff --git a/org.eclipse.app4mc.validation.cloud/openapi.yaml b/org.eclipse.app4mc.validation.cloud/openapi.yaml
new file mode 100644
index 0000000..61f8369
--- /dev/null
+++ b/org.eclipse.app4mc.validation.cloud/openapi.yaml
@@ -0,0 +1,200 @@
+openapi: 3.0.0
+info:
+  version: 0.9.9
+  title: APP4MC Validation API
+  description: APP4MC Validation API to validate an Amalthea model file
+
+servers:
+  - url: http://localhost:8080/app4mc/validation
+  
+paths:
+  /:
+    post:
+      summary: Upload the file to validate and start the validation process asynchronously
+      requestBody:
+        content:
+          multipart/form-data:
+            schema:
+              type: object
+              properties:
+                file:
+                  type: string
+                  format: binary
+      responses:
+        '201':
+          description: Upload succeeded and validation process started
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id:
+                    type: string
+                    description: ID of the created validation resource
+          headers:
+            Location:
+              schema:
+                type: string
+                format: uri
+              description: The URI to the status URL
+          links:
+            status:
+              operationId: getStatus
+              parameters:
+                statusId: '$response.body#/id'
+              description: >
+                The `id` value returned in the response can be used as
+                the `statusId` parameter in `GET /{statusId}`.
+        '400':
+          description: No model file provided as upload
+        '404':
+          description: Upload failed
+
+  /{statusId}:
+    get:
+      summary: Get the status of the triggered validation process
+      operationId: getStatus
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Processing finished successfully
+          headers:
+            Cache-Control:
+              schema:
+                type: string
+                enum:
+                  - private, no-store, no-cache, must-revalidate
+          links:
+            result:
+              operationId: getDownload
+              parameters:
+                statusId: $request.path.statusId
+        '202':
+          description: Validation process in progress
+          headers:
+            Cache-Control:
+              schema:
+                type: string
+                enum:
+                  - private, no-store, no-cache, must-revalidate
+        '204':
+          description: Processing finished with an error
+          headers:
+            Cache-Control:
+              schema:
+                type: string
+                enum:
+                  - private, no-store, no-cache, must-revalidate
+          links:
+            result:
+              operationId: getError
+              parameters:
+                statusId: $request.path.statusId
+        '404':
+          description: Resource not available
+
+    delete:
+      summary: Delete the uploaded and result resource from the server
+      operationId: deleteResource
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Resource deleted successfully
+        '404':
+          description: Resource not available
+
+  /{statusId}/download:
+    get:
+      summary: Download the validation result file
+      operationId: getDownload
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Validation successful
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+          headers:
+            # custom header to configure that the result should not be used as input to the next process in the workflow
+            x-app4mc-use-result:
+              schema:
+                type: string
+                enum:
+                  - false
+          links:
+            delete:
+              operationId: deleteResource
+              parameters:
+                statusId: $request.path.statusId
+        '400':
+          description: Validation finished with validation failures
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+          headers:
+            # custom header to configure that the result should not be used as input to the next process in the workflow
+            x-app4mc-use-result:
+              schema:
+                type: string
+                enum:
+                  - false
+          links:
+            delete:
+              operationId: deleteResource
+              parameters:
+                statusId: $request.path.statusId
+        '404':
+          description: Progress still running / Resource not available / No result available
+          content:
+            application/json:
+              schema:
+                type: string
+
+  /{statusId}/error:
+    get:
+      summary: Download the error file of the migration process
+      operationId: getError
+      parameters:
+        - in: path
+          name: statusId
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Error occured
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+          links:
+            delete:
+              operationId: deleteResource
+              parameters:
+                statusId: $request.path.statusId
+        '404':
+          description: Resource not available / No error occured
+          content:
+            application/json:
+              schema:
+                type: string
diff --git a/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/META-INF/MANIFEST.MF b/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/META-INF/MANIFEST.MF
index 6f79912..1096c34 100644
--- a/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/META-INF/MANIFEST.MF
+++ b/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/META-INF/MANIFEST.MF
@@ -9,7 +9,8 @@
  com.fasterxml.jackson.databind;version="2.9.93",
  javax.servlet;version="3.1.0",
  javax.servlet.http;version="3.1.0",
- org.osgi.service.component.annotations;version="1.3.0";resolution:=optional
+ org.osgi.service.component.annotations;version="1.3.0";resolution:=optional,
+ org.slf4j;version="1.7.2"
 Require-Bundle: org.eclipse.app4mc.amalthea.model;bundle-version="0.9.9",
  org.eclipse.app4mc.validation.core;bundle-version="0.9.9"
 Bundle-ActivationPolicy: lazy
diff --git a/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/src/org/eclipse/app4mc/validation/cloud/http/ValidationServlet.java b/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/src/org/eclipse/app4mc/validation/cloud/http/ValidationServlet.java
index 4a47952..82c54e8 100644
--- a/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/src/org/eclipse/app4mc/validation/cloud/http/ValidationServlet.java
+++ b/org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.http/src/org/eclipse/app4mc/validation/cloud/http/ValidationServlet.java
@@ -19,15 +19,22 @@
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.PrintStream;
+import java.io.PrintWriter;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import javax.servlet.Servlet;
+import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -42,6 +49,8 @@
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
 import org.osgi.service.component.annotations.ServiceScope;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -54,13 +63,26 @@
     },
     scope=ServiceScope.PROTOTYPE)
 public class ValidationServlet extends HttpServlet {
+	
+	private static final Logger LOGGER = LoggerFactory.getLogger(ValidationServlet.class);
  
     private static final long serialVersionUID = 1L;
     
     private static final String TEMP_DIR_PREFIX = "app4mc_validation_";
     
+	private static final String PROGRESS_MARKER = "in_progress";
+	private static final String ERROR_MARKER = "error";
+	private static final String FINISHED_MARKER = "finished";
+	
+	private static final String ERROR_FILE = "error.txt";
+
+	private static final String PASSED_MARKER = "passed";
+	private static final String FAILED_MARKER = "failed";
+
     private final String defaultBaseDir = System.getProperty("java.io.tmpdir");
- 
+	
+	private ExecutorService executor = Executors.newFixedThreadPool(1);
+
     @Reference
     private ProfileManager manager;
  
@@ -70,6 +92,10 @@
             return null;
 		}
 
+    	if (pathInfo.startsWith("/")) {
+    		pathInfo.substring(1);
+    	}
+    	
     	String[] splitPath = pathInfo.split("/");
     	
     	if (splitPath.length > 3) {
@@ -79,7 +105,7 @@
     	return splitPath;
     }
     
-    // POST /app4mc/validation/upload
+    // POST /app4mc/validation
 
     @Override
     protected void doPost(HttpServletRequest request, HttpServletResponse response)
@@ -87,116 +113,118 @@
     	
     	String[] splitPath = validatePath(request.getPathInfo());
     	
-    	if (splitPath == null || splitPath.length != 2) {
+    	String requestUrl = request.getRequestURL().toString();
+    	if (requestUrl.endsWith("/")) {
+    		requestUrl = requestUrl.substring(0, requestUrl.length() - 1);
+    	}
+    	
+    	if (splitPath != null && splitPath.length != 1) {
     		response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid path");
+    		response.addHeader("Link", "<" + requestUrl + ">;rel=\"self\"");
     		return;    		
     	}
     	
-    	if ("upload".equals(splitPath[1])){
-        	Part part = request.getPart("file");
-        	if (part != null) {
-        		String filename = part.getSubmittedFileName();
-        		try (InputStream is = part.getInputStream()) {
-        			Path path = Files.createTempDirectory(TEMP_DIR_PREFIX);
-        			
-        			// extract uuid from pathname
-        			String uuid = path.toString().substring(path.toString().lastIndexOf('_') + 1);
-        			
-        			Files.copy(is, Paths.get(path.toString(), filename));
-        			
-        			// return uuid
-        			response.setContentType("text/html");
-        			response.getWriter().write(uuid);
-        			return;
-        		}
-        	}
-		}
-    	
-    	// no content
-    	response.setStatus(HttpServletResponse.SC_NO_CONTENT);
-    	return;
-    }
-    
-    // PUT /app4mc/validation/{id}/validate
-    
-    @Override
-    protected void doPut(HttpServletRequest request, HttpServletResponse response)
-    		throws ServletException, IOException {
-    	
-    	String[] splitPath = validatePath(request.getPathInfo());
-    	
-    	if (splitPath == null || splitPath.length != 3) {
-    		response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid path");
-    		return;    		
-    	}
+    	Part part = request.getPart("file");
+    	if (part != null && part.getSubmittedFileName() != null && part.getSubmittedFileName().length() > 0) {
+    		String filename = part.getSubmittedFileName();
+    		try (InputStream is = part.getInputStream()) {
+    			Path tempFolderPath = Files.createTempDirectory(TEMP_DIR_PREFIX);
+    			
+    			// extract uuid from pathname
+    			String uuid = tempFolderPath.toString().substring(tempFolderPath.toString().lastIndexOf('_') + 1);
+    			
+    			Path uploaded = Paths.get(tempFolderPath.toString(), filename);
+    			Files.copy(is, uploaded);
+    			
+    			if (Files.exists(uploaded)) {
+    				// mark uuid in progress
+    				ServletContext context = getServletContext();
+    				Map<String, String> registry = getRegistry(context);
+    				registry.put(uuid, PROGRESS_MARKER);
+    				
+    				// trigger asynchronous processing
+    				executor.execute(() -> {
 
-    	String uuid = splitPath[1];
-    	String action = splitPath[2];
-    	
-    	if ("validate".equals(action)) {
-    		// trigger validation
-    		Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
-        	List<Path> modelFilePaths = 
-        			Files.find(tempFolderPath, 1, (path, attrs) -> path.toString().endsWith(".amxmi"))
-        			.collect(Collectors.toList());
-        	
-    		if (modelFilePaths.isEmpty()) {
-        		response.sendError(HttpServletResponse.SC_NOT_FOUND, "No model file uploaded!");
-        		return;
-    		}
-    		
-    		Path modelFilePath = modelFilePaths.get(0);
-    		
-    		// load uploaded model file
-    		Amalthea model = AmaltheaLoader.loadFromFile(modelFilePath.toFile());
-    		
-    		if (model == null) {
-        		response.sendError(HttpServletResponse.SC_NOT_FOUND, "Error: No model loaded!");
-    			return;
-    		}
-    		
-    		// get profile selection out of request
-    		String[] profiles = request.getParameterValues("profiles");
-    		List<String> selectedProfiles = profiles != null ? Arrays.asList(profiles) : Arrays.asList("Amalthea Standard Validations");
-    		
-    		// get selected profiles from profile manager
-    		List<Class<? extends IProfile>> profileList = manager.getRegisteredValidationProfiles().values().stream()
-    			.filter(profile -> selectedProfiles.contains(profile.getName()))
-    			.map(profile -> profile.getProfileClass())
-    			.collect(Collectors.toList());
-    		
-    		// TODO execute in background thread and introduce status resource?
-    		ValidationExecutor executor = new ValidationExecutor(profileList);
-    		executor.validate(model);
-    		
-    		// dump validation result to file
-    		Path resultFile = Files.createFile(Paths.get(tempFolderPath.toString(), "validation-results.txt"));
-    		try (PrintStream print = new PrintStream(new FileOutputStream(resultFile.toFile()))) {
-    			executor.dumpResultMap(print);
-    		}
+    					try {
+        		    		// load uploaded model file
+        		    		Amalthea model = AmaltheaLoader.loadFromFile(uploaded.toFile());
+        		    		
+        		    		if (model == null) {
+        		    			registry.put(uuid, ERROR_MARKER);
+        		    			error(tempFolderPath, "Error: No model loaded!", null);
+        		    			return;
+        		    		}
+        		    		
+        		    		// get profile selection out of request
+        		    		String[] profiles = request.getParameterValues("profiles");
+        		    		List<String> selectedProfiles = profiles != null ? Arrays.asList(profiles) : Arrays.asList("Amalthea Standard Validations");
+        		    		
+        		    		// get selected profiles from profile manager
+        		    		List<Class<? extends IProfile>> profileList = manager.getRegisteredValidationProfiles().values().stream()
+        		    			.filter(profile -> selectedProfiles.contains(profile.getName()))
+        		    			.map(profile -> profile.getProfileClass())
+        		    			.collect(Collectors.toList());
+        		    		
+        		    		ValidationExecutor executor = new ValidationExecutor(profileList);
+        		    		executor.validate(model);
+        		    		
+        		    		// dump validation result to file
+        		    		Path resultFile = Files.createFile(Paths.get(tempFolderPath.toString(), "validation-results.txt"));
+        		    		try (PrintStream print = new PrintStream(new FileOutputStream(resultFile.toFile()))) {
+        		    			executor.dumpResultMap(print);
+        		    		}
 
-    		boolean failed = executor.getResults().stream()
-    				.anyMatch(diag -> diag.getSeverity() == org.eclipse.emf.common.util.Diagnostic.ERROR);
-    		if (!failed) {
-    			response.setStatus(HttpServletResponse.SC_OK);
-    		} else {
-    			response.setStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
+        		    		boolean failed = executor.getResults().stream()
+        		    				.anyMatch(diag -> diag.getSeverity() == org.eclipse.emf.common.util.Diagnostic.ERROR);
+        		    		if (!failed) {
+        		    			Files.createFile(Paths.get(tempFolderPath.toString(), PASSED_MARKER));
+        		    		} else {
+        		    			Files.createFile(Paths.get(tempFolderPath.toString(), FAILED_MARKER));
+        		    		}
+    					} catch (IOException e) {
+    						LOGGER.error("Failed to write validation results", e);
+    		    			registry.put(uuid, ERROR_MARKER);
+    						error(tempFolderPath, "Failed to write validation results", e);
+						} finally {
+    						if (!ERROR_MARKER.equals(getRegistry(context).get(uuid))) {
+    							getRegistry(context).put(uuid, FINISHED_MARKER);
+    						}
+    					}
+    				});
+
+    				// return uuid
+    				response.setStatus(HttpServletResponse.SC_CREATED);
+    				response.addHeader("Location", requestUrl + "/" + uuid);
+    				response.addHeader("Link", "<" + requestUrl + ">;rel=\"self\"");
+    				response.addHeader("Link", "<" + requestUrl + "/" + uuid + ">;rel=\"status\"");
+    				response.setContentType("application/json");
+    				response.getWriter().write(uuid);
+    				return;
+    			} else {
+    	    		response.sendError(HttpServletResponse.SC_NOT_FOUND, "Model file upload failed!");
+    	    		response.addHeader("Link", "<" + requestUrl + ">;rel=\"self\"");
+    	    		return;    		
+    			}
     		}
-			return;
     	}
     	
-    	// no content
-    	response.setStatus(HttpServletResponse.SC_NO_CONTENT);
+    	// bad request without file
+    	response.sendError(HttpServletResponse.SC_BAD_REQUEST, "No model file provided!");
+    	response.addHeader("Link", "<" + request.getRequestURL() + ">;rel=\"self\"");
     	return;
     }
     
     // GET /app4mc/validation/profiles
+    // GET /app4mc/validation/{id}
     // GET /app4mc/validation/{id}/download
+    // GET /app4mc/validation/{id}/error
 
     @Override
     protected void doGet(HttpServletRequest request, HttpServletResponse response)
             throws ServletException, IOException {
  
+		response.addHeader("Link", "<" + request.getRequestURL() + ">;rel=\"self\"");
+
     	String[] splitPath = validatePath(request.getPathInfo());
     	
     	if (splitPath == null) {
@@ -219,11 +247,77 @@
             }
             
     		return;
-    	} else if (splitPath.length == 3 && "download".equals(splitPath[2])) {
-    		Path path = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + splitPath[1], "validation-results.txt");
+    	} else if (splitPath.length == 2) {
+    		response.setHeader("Cache-Control", "private, no-store, no-cache, must-revalidate");
+
+    		String uuid = splitPath[1];
     		
+    		// check for the in_progress marker file
+        	Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
+        	if (!Files.exists(tempFolderPath)) {
+	    		response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        		return;
+        	}
+        	
+        	boolean hasErrorFile = false;
+        	try (Stream<Path> files = Files.list(tempFolderPath)) {
+        		hasErrorFile = files.anyMatch(path -> path.endsWith(ERROR_FILE));
+        	}
+
+        	String status = getRegistry(getServletContext()).get(uuid);
+        	if (PROGRESS_MARKER.equals(status)) {
+        		response.setStatus(HttpServletResponse.SC_ACCEPTED);
+        		return;
+        	} else if (ERROR_MARKER.equals(status) || hasErrorFile) {
+            	// processing finished with error
+        		response.setStatus(HttpServletResponse.SC_NO_CONTENT);
+				response.addHeader("Link", "<" + request.getRequestURL() + "/error>;rel=\"error\"");
+        		return;
+        	}
+        	
+        	// processing is finished
+    		response.setStatus(HttpServletResponse.SC_OK);
+        	response.addHeader("Link", "<" + request.getRequestURL() + "/download>;rel=\"result\"");
+        	return;
+
+    	} else if (splitPath.length == 3 && "download".equals(splitPath[2])) {
+        	String status = getRegistry(getServletContext()).get(splitPath[1]);
+        	
+        	if (PROGRESS_MARKER.equals(status)) {
+        		response.sendError(HttpServletResponse.SC_NOT_FOUND, "Process is still in progresss");
+        		return;
+        	}
+
+        	Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + splitPath[1]);
+        	
+        	if (!Files.exists(tempFolderPath)) {
+	    		response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        		return;
+        	}
+    		
+        	// set status based on validation result
+        	boolean hasFailedMarker = false;
+        	try (Stream<Path> files = Files.list(tempFolderPath)) {
+        		hasFailedMarker = files.anyMatch(p -> p.endsWith(FAILED_MARKER));
+        	}
+        	
+        	if (hasFailedMarker) {
+        		response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+        	} else {
+        		response.setStatus(HttpServletResponse.SC_OK);
+        	}
+
+        	Path path = Paths.get(tempFolderPath.toString(), "validation-results.txt");
+        	if (!Files.exists(path)) {
+        		response.sendError(HttpServletResponse.SC_NOT_FOUND, "No validation result available");
+        		return;
+    		}
+    		
+    		response.addHeader("Link", "<" + request.getRequestURL().substring(0, request.getRequestURL().lastIndexOf("/")) + ">;rel=\"delete\"");
+    		response.setHeader("Content-Disposition","attachment; filename=\"" + path.toFile().getName() + "\"");
+    		// set special header to tell that the result should not be used in the next process step
+    		response.setHeader("x-app4mc-use-result", "false");
     		response.setContentType("text/plain");
-    		response.setHeader("Content-Disposition","attachment; filename=validation-results.txt");
     		
     		try (InputStream in = Files.newInputStream(path);
     				OutputStream out = response.getOutputStream()) {
@@ -235,6 +329,45 @@
     				out.write(buffer, 0, numBytesRead);
     			}
     		}
+    		return;
+    	} else if (splitPath.length == 3 && "error".equals(splitPath[2])) {
+    		String uuid = splitPath[1];
+        	Path tempFolderPath = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
+        	
+        	if (!Files.exists(tempFolderPath)) {
+	    		response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        		return;
+        	}
+        	
+        	// if there is no error file, the error resource is 404
+        	boolean hasErrorFile = false;
+        	try (Stream<Path> files = Files.list(tempFolderPath)) {
+        		hasErrorFile = files.anyMatch(path -> path.endsWith(ERROR_FILE));
+        	}
+
+        	if (!hasErrorFile) {
+	    		response.sendError(HttpServletResponse.SC_NOT_FOUND, "No error occured");
+        		return;
+        	}
+        	
+        	Path errorFilePath = Paths.get(tempFolderPath.toString(), ERROR_FILE);
+        	
+        	response.setStatus(HttpServletResponse.SC_OK);
+        	response.addHeader("Link", "<" + request.getRequestURL().substring(0, request.getRequestURL().lastIndexOf("/")) + ">;rel=\"delete\"");
+        	response.setHeader("Content-Disposition", "attachment; filename=\"" + errorFilePath.toFile().getName() + "\"");
+    		response.setContentType("text/plain");
+    		
+    		try (InputStream in = Files.newInputStream(errorFilePath);
+    				OutputStream out = response.getOutputStream()) {
+    		 
+    			byte[] buffer = new byte[4096];
+    		        
+    			int numBytesRead;
+    			while ((numBytesRead = in.read(buffer)) > 0) {
+    				out.write(buffer, 0, numBytesRead);
+    			}
+    		}
+        	return;
     	}
     	
     	// no content
@@ -242,36 +375,59 @@
     	return;
 	}
     
-    // DELETE /app4mc/validation/{id}/delete
+    
+    // DELETE /app4mc/validation/{id}
     
     @Override
     protected void doDelete(HttpServletRequest request, HttpServletResponse response)
     		throws ServletException, IOException {
 
     	String[] splitPath = validatePath(request.getPathInfo());
-    	
-    	if (splitPath == null || splitPath.length != 3) {
+
+    	if (splitPath == null || splitPath.length != 2) {
     		response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid path");
     		return;    		
     	}
 
     	String uuid = splitPath[1];
-    	String action = splitPath[2];
     	
-    	if ("delete".equals(action)) {
-    		Path path = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
-        	
-        	Files.walk(path)
-    	    	.sorted(Comparator.reverseOrder())
-    	    	.map(Path::toFile)
-    	    	.forEach(File::delete);
-        	
-        	response.setStatus(HttpServletResponse.SC_OK);
-			return;
-    	}
+		Path path = Paths.get(defaultBaseDir, TEMP_DIR_PREFIX + uuid);
+
+		if (Files.exists(path)) {
+			Files.walk(path)
+				.sorted(Comparator.reverseOrder())
+				.map(Path::toFile)
+				.forEach(File::delete);
+			
+			response.setStatus(HttpServletResponse.SC_OK);
+		} else {
+			// not found
+			response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+		}
     	
-    	// no content
-    	response.setStatus(HttpServletResponse.SC_NO_CONTENT);
     	return;
     }
+    
+	private void error(Path resultFolder, String message, Exception exception) {
+		try {
+			Path errorFilePath = Files.createFile(Paths.get(resultFolder.toString(), ERROR_FILE));
+			try (PrintWriter writer = new PrintWriter(Files.newOutputStream(errorFilePath))) {
+				writer.append(message).append(System.lineSeparator());
+				if (exception != null) {
+					exception.printStackTrace(writer);
+				}
+			}
+			
+		} catch (IOException e) {
+			LOGGER.error("Failed to write error.txt", e);
+		}
+	}
+
+	@SuppressWarnings("unchecked")
+	private static synchronized Map<String, String> getRegistry(ServletContext context) {
+		if (context.getAttribute("_REGISTRY") == null) {
+			context.setAttribute("_REGISTRY", new ConcurrentHashMap<String, String>());
+		}
+		return (Map<String, String>) context.getAttribute("_REGISTRY");
+	}
 }
\ No newline at end of file
diff --git a/org.eclipse.app4mc.validation.cloud/pom.xml b/org.eclipse.app4mc.validation.cloud/pom.xml
index 53f3521..f62fc1a 100644
--- a/org.eclipse.app4mc.validation.cloud/pom.xml
+++ b/org.eclipse.app4mc.validation.cloud/pom.xml
@@ -97,6 +97,27 @@
 					<groupId>org.eclipse.tycho</groupId>
 					<artifactId>tycho-p2-director-plugin</artifactId>
 					<version>${tycho.version}</version>
+					<executions>
+						<execution>
+							<id>materialize-products</id>
+							<goals>
+								<goal>materialize-products</goal>
+							</goals>
+						</execution>
+
+						<execution>
+							<id>archive-products</id>
+							<goals>
+								<goal>archive-products</goal>
+							</goals>
+							<configuration>
+								<formats>
+									<linux>tar.gz</linux>
+									<macosx>tar.gz</macosx>
+								</formats>
+							</configuration>
+						</execution>
+					</executions>
 				</plugin>
 			</plugins>
 		</pluginManagement>
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..e3e2607
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,5 @@
+start java -jar org.eclipse.app4mc.converter.cloud/converter-app/target/converter-app.jar
+start org.eclipse.app4mc.validation.cloud/org.eclipse.app4mc.validation.cloud.product/target/products/org.eclipse.app4mc.validation.cloud.product/win32/win32/x86_64/eclipse.exe -console
+start java -jar manager/target/manager-0.0.1-SNAPSHOT.jar
+timeout /T 10
+start firefox.exe "http://localhost:9090"
\ No newline at end of file