diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f585ef..22e7625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.0.1 - 2024-05-22] +## [0.0.1 - 2024-06-13] ### Added - First version of the project - Spring Application @@ -134,3 +134,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Check Queries in Exporter Job - Max time to wait focus task in minutes - Check export execution status +- Coder Service diff --git a/src/main/java/de/samply/app/ProjectManagerConst.java b/src/main/java/de/samply/app/ProjectManagerConst.java index f362dd2..aa0f3cb 100644 --- a/src/main/java/de/samply/app/ProjectManagerConst.java +++ b/src/main/java/de/samply/app/ProjectManagerConst.java @@ -269,6 +269,17 @@ public class ProjectManagerConst { public final static String TOKEN_MANAGER_PARAMETER_TOKEN_STATUS = "token_status"; public final static String TOKEN_MANAGER_PARAMETER_TOKEN_CREATED_AT = "token_created_at"; + // Coder + public final static String CODER_API_PATH = "/api/v2"; + public final static String CODER_SESSION_TOKEN_HEADER = "Coder-Session-Token"; + + public final static String CODER_ENABLE_JUPYTER_LAB_PARAM_KEY = "Enable Jupyter Lab?"; + public final static String CODER_ENABLE_VS_CODE_SERVER_PARAM_KEY = "Enable VS Code Server?"; + public final static String CODER_DOTFILES_URL_PARAM_KEY = "Your Dotfiles URL"; + public final static String CODER_ENABLE_FILE_RECEIVER_PARAM_KEY = "Samply.Beam: Enable file receiver"; + public final static String CODER_SAMPLY_BEAM_APP_ID_PARAM_KEY = "Samply.Beam: App ID (short)"; + public final static String CODER_SAMPLY_BEAM_APP_SECRET_PARAM_KEY = "Samply.Beam: App Secret"; + public final static String CODER_DELETE_TRANSITION = "delete"; // Environment Variables public final static String PM_ADMIN_GROUPS = "PM_ADMIN_GROUPS"; @@ -319,6 +330,21 @@ public class ProjectManagerConst { public final static String ENABLE_EXPORTER = "ENABLE_EXPORTER"; public final static String MAX_TIME_TO_WAIT_FOCUS_TASK_IN_MINUTES = "MAX_TIME_TO_WAIT_FOCUS_TASK_IN_MINUTES"; + public final static String CODER_BASE_URL = "CODER_BASE_URL"; + public final static String CODER_ORGANISATION_ID = "CODER_ORGANISATION_ID"; + public final static String CODER_MEMBER_ID = "CODER_MEMBER_ID"; + public final static String CODER_WORKSPACE_ID = "CODER_WORKSPACE_ID"; + public final static String CODER_TEMPLATE_VERSION_ID = "CODER_TEMPLATE_VERSION_ID"; + public final static String CODER_CREATE_PATH = "CODER_CREATE_PATH"; + public final static String CODER_DELETE_PATH = "CODER_DELETE_PATH"; + public final static String CODER_SESSION_TOKEN = "CODER_SESSION_TOKEN"; + + public final static String CODER_ENABLE_JUPYTER_LAB_PARAM_VALUE = "CODER_ENABLE_JUPYTER_LAB_PARAM_VALUE"; + public final static String CODER_ENABLE_VS_CODE_SERVER_PARAM_VALUE = "CODER_ENABLE_VS_CODE_SERVER_PARAM_VALUE"; + public final static String CODER_DOTFILES_URL_PARAM_VALUE = "CODER_DOTFILES_URL_PARAM_VALUE"; + public final static String CODER_ENABLE_FILE_RECEIVER_PARAM_VALUE = "CODER_ENABLE_FILE_RECEIVER_PARAM_VALUE"; + public final static String ENABLE_CODER = "ENABLE_CODER"; + // Spring Values (SV) public final static String HEAD_SV = "${"; public final static String BOTTOM_SV = "}"; @@ -387,7 +413,19 @@ public class ProjectManagerConst { public final static String ENABLE_RSTUDIO_GROUP_MANAGER_SV = HEAD_SV + ENABLE_RSTUDIO_GROUP_MANAGER + ":true" + BOTTOM_SV; public final static String OIDC_URL_SV = HEAD_SV + OIDC_URL + BOTTOM_SV; public final static String OIDC_REALM_SV = HEAD_SV + OIDC_REALM + BOTTOM_SV; + public final static String CODER_BASE_URL_SV = HEAD_SV + CODER_BASE_URL + BOTTOM_SV; + public final static String CODER_ORGANISATION_ID_SV = HEAD_SV + CODER_ORGANISATION_ID + BOTTOM_SV; + public final static String CODER_MEMBER_ID_SV = HEAD_SV + CODER_MEMBER_ID + BOTTOM_SV; + public final static String CODER_TEMPLATE_VERSION_ID_SV = HEAD_SV + CODER_TEMPLATE_VERSION_ID + BOTTOM_SV; + public final static String CODER_CREATE_PATH_SV = HEAD_SV + CODER_CREATE_PATH + BOTTOM_SV; + public final static String CODER_DELETE_PATH_SV = HEAD_SV + CODER_DELETE_PATH + BOTTOM_SV; + public final static String CODER_SESSION_TOKEN_SV = HEAD_SV + CODER_SESSION_TOKEN + BOTTOM_SV; + public final static String CODER_ENABLE_JUPYTER_LAB_PARAM_VALUE_SV = HEAD_SV + CODER_ENABLE_JUPYTER_LAB_PARAM_VALUE + ":1" + BOTTOM_SV; + public final static String CODER_ENABLE_VS_CODE_SERVER_PARAM_VALUE_SV = HEAD_SV + CODER_ENABLE_VS_CODE_SERVER_PARAM_VALUE + ":0" + BOTTOM_SV; + public final static String CODER_DOTFILES_URL_PARAM_VALUE_SV = HEAD_SV + CODER_DOTFILES_URL_PARAM_VALUE + ":" + BOTTOM_SV; + public final static String CODER_ENABLE_FILE_RECEIVER_PARAM_VALUE_SV = HEAD_SV + CODER_ENABLE_FILE_RECEIVER_PARAM_VALUE + ":1" + BOTTOM_SV; + public final static String ENABLE_CODER_SV = HEAD_SV + ENABLE_CODER + ":true" + BOTTOM_SV; // Others public final static String TEST_EMAIL = "test@project-manager.com"; diff --git a/src/main/java/de/samply/coder/CoderService.java b/src/main/java/de/samply/coder/CoderService.java new file mode 100644 index 0000000..9486410 --- /dev/null +++ b/src/main/java/de/samply/coder/CoderService.java @@ -0,0 +1,219 @@ +package de.samply.coder; + +import de.samply.app.ProjectManagerConst; +import de.samply.coder.request.CreateRequestBody; +import de.samply.coder.request.CreateRequestParameter; +import de.samply.coder.request.Response; +import de.samply.coder.request.TransitionRequestBody; +import de.samply.db.model.Project; +import de.samply.db.model.ProjectCoder; +import de.samply.db.repository.ProjectCoderRepository; +import de.samply.db.repository.ProjectRepository; +import de.samply.notification.NotificationService; +import de.samply.notification.OperationType; +import de.samply.utils.WebClientFactory; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +@Slf4j +@Service +public class CoderService { + + private final boolean coderEnabled; + private final ProjectRepository projectRepository; + private final ProjectCoderRepository projectCoderRepository; + private final NotificationService notificationService; + private final String coderTemplateVersionId; + private final String coderCreatePath; + private final String coderDeletePath; + private final String enableJupyterLab; + private final String enableVsCodeServer; + private final String dotFilesUrl; + private final String enableFileReceiver; + private final String coderSessionToken; + + private final WebClient webClient; + + public CoderService( + ProjectCoderRepository projectCoderRepository, + NotificationService notificationService, + ProjectRepository projectRepository, + @Value(ProjectManagerConst.ENABLE_CODER_SV) boolean coderEnabled, + @Value(ProjectManagerConst.CODER_BASE_URL_SV) String coderBaseUrl, + @Value(ProjectManagerConst.CODER_ORGANISATION_ID_SV) String coderOrganizationId, + @Value(ProjectManagerConst.CODER_MEMBER_ID_SV) String coderMemberId, + @Value(ProjectManagerConst.CODER_TEMPLATE_VERSION_ID_SV) String coderTemplateVersionId, + @Value(ProjectManagerConst.CODER_CREATE_PATH_SV) String coderCreatePath, + @Value(ProjectManagerConst.CODER_DELETE_PATH_SV) String coderDeletePath, + @Value(ProjectManagerConst.CODER_ENABLE_JUPYTER_LAB_PARAM_VALUE_SV) String enableJupyterLab, + @Value(ProjectManagerConst.CODER_ENABLE_VS_CODE_SERVER_PARAM_VALUE_SV) String enableVsCodeServer, + @Value(ProjectManagerConst.CODER_DOTFILES_URL_PARAM_VALUE_SV) String dotFilesUrl, + @Value(ProjectManagerConst.CODER_ENABLE_FILE_RECEIVER_PARAM_VALUE_SV) String enableFileReceiver, + @Value(ProjectManagerConst.CODER_SESSION_TOKEN_SV) String coderSessionToken, + WebClientFactory webClientFactory) { + this.coderEnabled = coderEnabled; + this.projectCoderRepository = projectCoderRepository; + this.notificationService = notificationService; + this.projectRepository = projectRepository; + this.enableJupyterLab = enableJupyterLab; + this.enableVsCodeServer = enableVsCodeServer; + this.dotFilesUrl = dotFilesUrl; + this.enableFileReceiver = enableFileReceiver; + this.coderSessionToken = coderSessionToken; + Map pathVariables = Map.of(ProjectManagerConst.CODER_ORGANISATION_ID, coderOrganizationId, + ProjectManagerConst.CODER_MEMBER_ID, coderMemberId); + this.coderCreatePath = replaceVariablesInPath(coderCreatePath, pathVariables); + this.coderDeletePath = replaceVariablesInPath(coderDeletePath, pathVariables); + + this.coderTemplateVersionId = coderTemplateVersionId; + this.webClient = webClientFactory.createWebClient(coderBaseUrl); + } + + private String replaceVariablesInPath(String path, Map pathVariables) { + AtomicReference result = new AtomicReference<>(path); + if (path != null) { + pathVariables.entrySet().stream().filter(entry -> entry.getKey() != null && entry.getValue() != null) + .forEach(entry -> result.set(result.get().replace(fetchVariableExpresion(entry.getKey()), entry.getValue()))); + } + + return result.get(); + } + + private String fetchVariableExpresion(String variable) { + return "{" + variable + "}"; + } + + public void createWorkspace(String email, String projectCode) throws CoderServiceException { + Optional project = projectRepository.findByCode(projectCode); + if (project.isEmpty()) { + throw new CoderServiceException("Project " + projectCode + " not found"); + } + createWorkspace(email, project.get()); + } + + public void createWorkspace(@NotNull String email, @NotNull Project project) { + if (coderEnabled) { + ProjectCoder projectCoder = generateProjectCoder(email, project); + CreateRequestBody createRequestBody = generateCreateRequestBody(projectCoder); + Response response = createWorkspace(projectCoder, createRequestBody).block(); + projectCoder.setWorkspaceId(response.getLatestBuild().getWorkspaceId()); + projectCoderRepository.save(projectCoder); + notificationService.createNotification(project.getCode(), null, email, OperationType.CREATE_CODER_WORKSPACE, + "Created workspace " + projectCoder.getWorkspaceId(), null, null); + + } + } + + private Mono createWorkspace(ProjectCoder projectCoder, CreateRequestBody createRequestBody) { + return this.webClient.post() + .uri(uriBuilder -> uriBuilder.path(ProjectManagerConst.CODER_API_PATH).path(coderCreatePath).build()) + .header(ProjectManagerConst.CODER_SESSION_TOKEN_HEADER, coderSessionToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(createRequestBody) + .exchangeToMono(clientResponse -> { + if (clientResponse.statusCode().equals(HttpStatus.OK) || clientResponse.statusCode().equals(HttpStatus.CREATED)) { + return clientResponse.bodyToMono(Response.class); + } else { + log.error("Http error " + clientResponse.statusCode() + " creating workspace in Coder for user " + + projectCoder.getEmail() + " in project " + projectCoder.getProject().getCode()); + return clientResponse.bodyToMono(String.class).flatMap(errorBody -> { + log.error("Error: {}", errorBody); + return Mono.error(new RuntimeException(errorBody)); + }); + } + }); + } + + private ProjectCoder generateProjectCoder(String email, Project project) { + ProjectCoder projectCoder = new ProjectCoder(); + projectCoder.setProject(project); + projectCoder.setEmail(email); + projectCoder.setAppId(fetchCoderAppId(email, project)); + projectCoder.setAppSecret(generateAppSecret()); + return projectCoder; + } + + public void deleteWorkspace(@NotNull String email, @NotNull String projectCode) throws CoderServiceException { + Optional project = projectRepository.findByCode(projectCode); + if (project.isEmpty()) { + throw new CoderServiceException("Project " + projectCode + " not found"); + } + deleteWorkspace(email, project.get()); + } + + public void deleteWorkspace(@NotNull String email, @NotNull Project project) { + if (coderEnabled) { + projectCoderRepository.findByProjectAndEmail(project, email) + .filter(projectCoder -> projectCoder.getDeletedAt() == null).ifPresent(projectCoder -> { + deleteWorkspace(projectCoder).block(); + projectCoder.setDeletedAt(Instant.now()); + projectCoderRepository.save(projectCoder); + notificationService.createNotification(project.getCode(), null, email, OperationType.DELETE_CODER_WORKSPACE, + "Deleted workspace " + projectCoder.getWorkspaceId(), null, null); + }); + } + } + + private Mono deleteWorkspace(ProjectCoder projectCoder) { + return this.webClient.post() + .uri(uriBuilder -> uriBuilder.path(ProjectManagerConst.CODER_API_PATH) + .path(replaceVariablesInPath(coderDeletePath, Map.of(ProjectManagerConst.CODER_WORKSPACE_ID, projectCoder.getWorkspaceId()))).build()) + .header(ProjectManagerConst.CODER_SESSION_TOKEN_HEADER, coderSessionToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(new TransitionRequestBody(ProjectManagerConst.CODER_DELETE_TRANSITION)) + .exchangeToMono(clientResponse -> { + if (clientResponse.statusCode().equals(HttpStatus.OK) || clientResponse.statusCode().equals(HttpStatus.CREATED)) { + return clientResponse.bodyToMono(Response.class); + } else { + log.error("Http error " + clientResponse.statusCode() + " deleting workspace in Coder for user " + + projectCoder.getEmail() + " in project " + projectCoder.getProject().getCode()); + return clientResponse.bodyToMono(String.class).flatMap(errorBody -> { + log.error("Error: {}", errorBody); + return Mono.error(new RuntimeException(errorBody)); + }); + } + }); + } + + private CreateRequestBody generateCreateRequestBody(ProjectCoder projectCoder) { + CreateRequestBody createRequestBody = new CreateRequestBody(); + createRequestBody.setTemplateVersionId(coderTemplateVersionId); + createRequestBody.setName(projectCoder.getAppId()); + addRichParameterValues(createRequestBody, projectCoder); + return createRequestBody; + } + + private void addRichParameterValues(CreateRequestBody createRequestBody, ProjectCoder projectCoder) { + List createRequestParameters = List.of( + new CreateRequestParameter(ProjectManagerConst.CODER_ENABLE_JUPYTER_LAB_PARAM_KEY, enableJupyterLab), + new CreateRequestParameter(ProjectManagerConst.CODER_ENABLE_VS_CODE_SERVER_PARAM_KEY, enableVsCodeServer), + new CreateRequestParameter(ProjectManagerConst.CODER_DOTFILES_URL_PARAM_KEY, dotFilesUrl), + new CreateRequestParameter(ProjectManagerConst.CODER_ENABLE_FILE_RECEIVER_PARAM_KEY, enableFileReceiver), + new CreateRequestParameter(ProjectManagerConst.CODER_SAMPLY_BEAM_APP_ID_PARAM_KEY, projectCoder.getAppId()), + new CreateRequestParameter(ProjectManagerConst.CODER_SAMPLY_BEAM_APP_SECRET_PARAM_KEY, projectCoder.getAppSecret()) + ); + createRequestBody.setRichParameterValues(createRequestParameters.toArray(CreateRequestParameter[]::new)); + } + + public String fetchCoderAppId(@NotNull String email, @NotNull Project project) { + return email.substring(0, email.indexOf("@")).replace(".", "-") + "-" + project.getCode(); + } + + private String generateAppSecret() { + return UUID.randomUUID().toString(); + } + +} diff --git a/src/main/java/de/samply/coder/CoderServiceException.java b/src/main/java/de/samply/coder/CoderServiceException.java new file mode 100644 index 0000000..b5f887d --- /dev/null +++ b/src/main/java/de/samply/coder/CoderServiceException.java @@ -0,0 +1,7 @@ +package de.samply.coder; + +public class CoderServiceException extends RuntimeException{ + public CoderServiceException(String message) { + super(message); + } +} diff --git a/src/main/java/de/samply/coder/request/Build.java b/src/main/java/de/samply/coder/request/Build.java new file mode 100644 index 0000000..e68da82 --- /dev/null +++ b/src/main/java/de/samply/coder/request/Build.java @@ -0,0 +1,15 @@ +package de.samply.coder.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class Build { + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("workspace_id") + private String workspaceId; + +} diff --git a/src/main/java/de/samply/coder/request/CreateRequestBody.java b/src/main/java/de/samply/coder/request/CreateRequestBody.java new file mode 100644 index 0000000..60bb4c8 --- /dev/null +++ b/src/main/java/de/samply/coder/request/CreateRequestBody.java @@ -0,0 +1,18 @@ +package de.samply.coder.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class CreateRequestBody { + + @JsonProperty("name") + private String name; + + @JsonProperty("rich_parameter_values") + private CreateRequestParameter[] richParameterValues; + + @JsonProperty("template_version_id") + private String templateVersionId; + +} diff --git a/src/main/java/de/samply/coder/request/CreateRequestParameter.java b/src/main/java/de/samply/coder/request/CreateRequestParameter.java new file mode 100644 index 0000000..5df3223 --- /dev/null +++ b/src/main/java/de/samply/coder/request/CreateRequestParameter.java @@ -0,0 +1,23 @@ +package de.samply.coder.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class CreateRequestParameter { + + @JsonProperty("name") + private String name; + + @JsonProperty("value") + private String value; + + public CreateRequestParameter() { + } + + public CreateRequestParameter(String name, String value) { + this.name = name; + this.value = value; + } + +} diff --git a/src/main/java/de/samply/coder/request/Response.java b/src/main/java/de/samply/coder/request/Response.java new file mode 100644 index 0000000..30821e1 --- /dev/null +++ b/src/main/java/de/samply/coder/request/Response.java @@ -0,0 +1,22 @@ +package de.samply.coder.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class Response { + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("id") + private String id; + + @JsonProperty("latest_build") + private Build latestBuild; + + @JsonProperty("status") + private String status; + + +} diff --git a/src/main/java/de/samply/coder/request/TransitionRequestBody.java b/src/main/java/de/samply/coder/request/TransitionRequestBody.java new file mode 100644 index 0000000..090b84b --- /dev/null +++ b/src/main/java/de/samply/coder/request/TransitionRequestBody.java @@ -0,0 +1,22 @@ +package de.samply.coder.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TransitionRequestBody { + + @JsonProperty("transition") + private String transition; + + @JsonProperty("orphan") + private boolean orphan = false; + + public TransitionRequestBody() { + } + + public TransitionRequestBody(String transition) { + this.transition = transition; + } + +} diff --git a/src/main/java/de/samply/db/model/ProjectCoder.java b/src/main/java/de/samply/db/model/ProjectCoder.java new file mode 100644 index 0000000..5379ecd --- /dev/null +++ b/src/main/java/de/samply/db/model/ProjectCoder.java @@ -0,0 +1,45 @@ +package de.samply.db.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Table(name = "project_coder", schema = "samply") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProjectCoder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "app_id", nullable = false) + private String appId; + + @Column(name = "app_secret", nullable = false) + private String appSecret; + + @Column(name = "email", nullable = false) + private String email; + + @ManyToOne + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Column(name = "workspace_id") + private String workspaceId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "deleted_at") + private Instant deletedAt; + + +} diff --git a/src/main/java/de/samply/db/repository/ProjectCoderRepository.java b/src/main/java/de/samply/db/repository/ProjectCoderRepository.java new file mode 100644 index 0000000..44d0966 --- /dev/null +++ b/src/main/java/de/samply/db/repository/ProjectCoderRepository.java @@ -0,0 +1,15 @@ +package de.samply.db.repository; + +import de.samply.db.model.Project; +import de.samply.db.model.ProjectCoder; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ProjectCoderRepository extends JpaRepository { + + Optional findByProjectAndEmail (Project project, String email); + +} diff --git a/src/main/java/de/samply/notification/OperationType.java b/src/main/java/de/samply/notification/OperationType.java index c8dd290..b726754 100644 --- a/src/main/java/de/samply/notification/OperationType.java +++ b/src/main/java/de/samply/notification/OperationType.java @@ -16,5 +16,7 @@ public enum OperationType { EDIT_QUERY, ADD_DOCUMENT, EDIT_PROJECT, - SEND_EMAIL + SEND_EMAIL, + CREATE_CODER_WORKSPACE, + DELETE_CODER_WORKSPACE } diff --git a/src/main/java/de/samply/rstudio/group/RstudioGroupManagerException.java b/src/main/java/de/samply/rstudio/group/RstudioGroupManagerException.java deleted file mode 100644 index 1d12926..0000000 --- a/src/main/java/de/samply/rstudio/group/RstudioGroupManagerException.java +++ /dev/null @@ -1,7 +0,0 @@ -package de.samply.rstudio.group; - -public class RstudioGroupManagerException extends RuntimeException{ - public RstudioGroupManagerException(Throwable cause) { - super(cause); - } -} diff --git a/src/main/java/de/samply/rstudio/group/RstudioGroupManager.java b/src/main/java/de/samply/rstudio/group/RstudioGroupService.java similarity index 67% rename from src/main/java/de/samply/rstudio/group/RstudioGroupManager.java rename to src/main/java/de/samply/rstudio/group/RstudioGroupService.java index 175649c..ff029d0 100644 --- a/src/main/java/de/samply/rstudio/group/RstudioGroupManager.java +++ b/src/main/java/de/samply/rstudio/group/RstudioGroupService.java @@ -1,9 +1,9 @@ package de.samply.rstudio.group; -public interface RstudioGroupManager { +public interface RstudioGroupService { - void addUserToRstudioGroup(String email) throws RstudioGroupManagerException; + void addUserToRstudioGroup(String email) throws RstudioGroupServiceException; - void removeUserFromRstudioGroup(String email) throws RstudioGroupManagerException; + void removeUserFromRstudioGroup(String email) throws RstudioGroupServiceException; } diff --git a/src/main/java/de/samply/rstudio/group/RstudioGroupServiceException.java b/src/main/java/de/samply/rstudio/group/RstudioGroupServiceException.java new file mode 100644 index 0000000..754e75b --- /dev/null +++ b/src/main/java/de/samply/rstudio/group/RstudioGroupServiceException.java @@ -0,0 +1,7 @@ +package de.samply.rstudio.group; + +public class RstudioGroupServiceException extends RuntimeException{ + public RstudioGroupServiceException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/de/samply/rstudio/group/RstudioKeycloakGroupManager.java b/src/main/java/de/samply/rstudio/group/RstudioKeycloakGroupService.java similarity index 96% rename from src/main/java/de/samply/rstudio/group/RstudioKeycloakGroupManager.java rename to src/main/java/de/samply/rstudio/group/RstudioKeycloakGroupService.java index 110a434..ceac44b 100644 --- a/src/main/java/de/samply/rstudio/group/RstudioKeycloakGroupManager.java +++ b/src/main/java/de/samply/rstudio/group/RstudioKeycloakGroupService.java @@ -20,7 +20,7 @@ @Slf4j @Service @ConditionalOnProperty(name = ProjectManagerConst.RSTUDIO_GROUP_IMPLEMENTATION, havingValue = ProjectManagerConst.RSTUDIO_GROUP_KEYCLOAK_IMPLEMENTATION) -public class RstudioKeycloakGroupManager implements RstudioGroupManager { +public class RstudioKeycloakGroupService implements RstudioGroupService { private final ObjectMapper objectMapper = new ObjectMapper(); private final String tokenRequestBody; @@ -29,7 +29,7 @@ public class RstudioKeycloakGroupManager implements RstudioGroupManager { private final WebClient webClient; private final boolean enabled; - public RstudioKeycloakGroupManager( + public RstudioKeycloakGroupService( @Value(ProjectManagerConst.OIDC_URL_SV) String keycloakUrl, @Value(ProjectManagerConst.KEYCLOAK_RSTUDIO_GROUP_CLIENT_ID_SV) String clientId, @Value(ProjectManagerConst.KEYCLOAK_RSTUDIO_GROUP_CLIENT_SECRET_SV) String clientSecret, @@ -59,7 +59,7 @@ private void append(StringBuilder stringBuilder, String key, String value) { @Override - public void addUserToRstudioGroup(String email) throws RstudioGroupManagerException { + public void addUserToRstudioGroup(String email) throws RstudioGroupServiceException { if (enabled) { log.info("Adding user " + email + " to Rstudio-Group"); addUserToGroup(email); @@ -67,7 +67,7 @@ public void addUserToRstudioGroup(String email) throws RstudioGroupManagerExcept } @Override - public void removeUserFromRstudioGroup(String email) throws RstudioGroupManagerException { + public void removeUserFromRstudioGroup(String email) throws RstudioGroupServiceException { if (enabled) { log.info("Removing user " + email + " from Rstudio-Group"); removeUserFromGroup(email); @@ -151,7 +151,7 @@ private String extractAttribute(String jsonObject, Function try { return attributeExtractor.apply(objectMapper.readTree(jsonObject)).asText(); } catch (Exception e) { - throw new RstudioGroupManagerException(e); + throw new RstudioGroupServiceException(e); } } diff --git a/src/main/java/de/samply/token/DataShieldTokenManagerJob.java b/src/main/java/de/samply/token/DataShieldTokenManagerJob.java index 7a01a88..159a589 100644 --- a/src/main/java/de/samply/token/DataShieldTokenManagerJob.java +++ b/src/main/java/de/samply/token/DataShieldTokenManagerJob.java @@ -2,6 +2,7 @@ import de.samply.app.ProjectManagerConst; import de.samply.bridgehead.BridgeheadConfiguration; +import de.samply.coder.CoderService; import de.samply.db.model.ProjectBridgehead; import de.samply.db.model.ProjectBridgeheadDataShield; import de.samply.db.model.ProjectBridgeheadUser; @@ -14,7 +15,7 @@ import de.samply.project.ProjectType; import de.samply.project.state.ProjectBridgeheadState; import de.samply.project.state.ProjectState; -import de.samply.rstudio.group.RstudioGroupManager; +import de.samply.rstudio.group.RstudioGroupService; import de.samply.token.dto.DataShieldProjectStatus; import de.samply.token.dto.DataShieldTokenManagerProjectStatus; import de.samply.token.dto.DataShieldTokenManagerTokenStatus; @@ -32,7 +33,8 @@ @Component public class DataShieldTokenManagerJob { - private final RstudioGroupManager rstudioGroupManager; + private final RstudioGroupService rstudioGroupService; + private final CoderService coderService; private final DataShieldTokenManagerService tokenManagerService; private final ProjectBridgeheadUserRepository projectBridgeheadUserRepository; private final ProjectBridgeheadRepository projectBridgeheadRepository; @@ -41,7 +43,8 @@ public class DataShieldTokenManagerJob { private final BridgeheadConfiguration bridgeheadConfiguration; private final boolean isTokenManagerActive; - public DataShieldTokenManagerJob(RstudioGroupManager rstudioGroupManager, + public DataShieldTokenManagerJob(RstudioGroupService rstudioGroupService, + CoderService coderService, DataShieldTokenManagerService tokenManagerService, ProjectBridgeheadUserRepository projectBridgeheadUserRepository, ProjectBridgeheadRepository projectBridgeheadRepository, @@ -49,7 +52,8 @@ public DataShieldTokenManagerJob(RstudioGroupManager rstudioGroupManager, EmailService emailService, BridgeheadConfiguration bridgeheadConfiguration, @Value(ProjectManagerConst.ENABLE_TOKEN_MANAGER_SV) boolean isTokenManagerActive ) { - this.rstudioGroupManager = rstudioGroupManager; + this.rstudioGroupService = rstudioGroupService; + this.coderService = coderService; this.tokenManagerService = tokenManagerService; this.projectBridgeheadUserRepository = projectBridgeheadUserRepository; this.projectBridgeheadRepository = projectBridgeheadRepository; @@ -90,7 +94,8 @@ private void manageActiveUsers() { })); usersToSendAnEmail.forEach(userProject -> { sendEmail(userProject.getEmail(), userProject.getProjectCode(), EmailTemplateType.NEW_TOKEN_FOR_AUTHENTICATION_SCRIPT, userProject.getProjectRole()); - this.rstudioGroupManager.addUserToRstudioGroup(userProject.getEmail()); + this.rstudioGroupService.addUserToRstudioGroup(userProject.getEmail()); + this.coderService.createWorkspace(userProject.getEmail(), userProject.getProjectCode()); }); } @@ -130,7 +135,8 @@ private void manageInactiveUsers() { sendEmail(userProject.getEmail(), userProject.getProjectCode(), EmailTemplateType.INVALID_AUTHENTICATION_SCRIPT, userProject.getProjectRole()); if (userProject.getProjectRole() != ProjectRole.FINAL || this.projectBridgeheadRepository.findByProjectCodeAndState(userProject.getProjectCode(), ProjectBridgeheadState.ACCEPTED).isEmpty()) { - this.rstudioGroupManager.removeUserFromRstudioGroup(userProject.getEmail()); + this.rstudioGroupService.removeUserFromRstudioGroup(userProject.getEmail()); + this.coderService.deleteWorkspace(userProject.getEmail(), userProject.getProjectCode()); } }); } diff --git a/src/main/resources/db/migration/V001__initialize_schema_and_tables.sql b/src/main/resources/db/migration/V001__initialize_schema_and_tables.sql index 7f1a2da..2f9b864 100644 --- a/src/main/resources/db/migration/V001__initialize_schema_and_tables.sql +++ b/src/main/resources/db/migration/V001__initialize_schema_and_tables.sql @@ -114,6 +114,17 @@ CREATE TABLE samply.project_bridgehead_datashield removed BOOLEAN NOT NULL ); +CREATE TABLE samply.project_coder +( + id SERIAL PRIMARY KEY, + app_id TEXT NOT NULL, + app_secret TEXT NOT NULL, + email TEXT NOT NULL, + project_id BIGINT NOT NULL, + workspace_id TEXT, + created_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP +); ALTER TABLE samply.project ADD CONSTRAINT fk_project_query @@ -147,10 +158,15 @@ ALTER TABLE samply.project_bridgehead_datashield ADD CONSTRAINT fk_project_bridgehead_datashield FOREIGN KEY (project_bridgehead_id) REFERENCES samply.project_bridgehead (id); +ALTER TABLE samply.project_coder + ADD CONSTRAINT fk_project FOREIGN KEY (project_id) + REFERENCES samply.project (id); + CREATE INDEX idx_project_bridgehead_project_id ON samply.project_bridgehead (project_id); CREATE INDEX idx_project_bridgehead_user_project_bridgehead_id ON samply.project_bridgehead_user (project_bridgehead_id); CREATE INDEX idx_project_document_project_id ON samply.project_document (project_id); CREATE INDEX idx_project_query_id ON samply.project (query_id); CREATE INDEX idx_notification_project_id ON samply.notification (project_id); -CREATE INDEX idx_notification_id ON samply.notification_user_action (notification_id); -CREATE INDEX idx_project_bridgehead_id ON samply.project_bridgehead_datashield (project_bridgehead_id); +CREATE INDEX idx_notification_user_action_notification_id ON samply.notification_user_action (notification_id); +CREATE INDEX idx_project_bridgehead_datashield_project_bridgehead_id ON samply.project_bridgehead_datashield (project_bridgehead_id); +CREATE INDEX idx_project_coder_project_id ON samply.project_coder (project_id);