Skip to content

Commit

Permalink
Added: Coder Service
Browse files Browse the repository at this point in the history
  • Loading branch information
djuarezgf committed Jun 13, 2024
1 parent b58b9c1 commit fb93acf
Show file tree
Hide file tree
Showing 18 changed files with 474 additions and 25 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
38 changes: 38 additions & 0 deletions src/main/java/de/samply/app/ProjectManagerConst.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = "}";
Expand Down Expand Up @@ -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";
Expand Down
219 changes: 219 additions & 0 deletions src/main/java/de/samply/coder/CoderService.java
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<String, String> pathVariables) {
AtomicReference<String> 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> 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<Response> 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> 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<Response> 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<CreateRequestParameter> 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();
}

}
7 changes: 7 additions & 0 deletions src/main/java/de/samply/coder/CoderServiceException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.samply.coder;

public class CoderServiceException extends RuntimeException{
public CoderServiceException(String message) {
super(message);
}
}
15 changes: 15 additions & 0 deletions src/main/java/de/samply/coder/request/Build.java
Original file line number Diff line number Diff line change
@@ -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;

}
18 changes: 18 additions & 0 deletions src/main/java/de/samply/coder/request/CreateRequestBody.java
Original file line number Diff line number Diff line change
@@ -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;

}
23 changes: 23 additions & 0 deletions src/main/java/de/samply/coder/request/CreateRequestParameter.java
Original file line number Diff line number Diff line change
@@ -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;
}

}
22 changes: 22 additions & 0 deletions src/main/java/de/samply/coder/request/Response.java
Original file line number Diff line number Diff line change
@@ -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;


}
Loading

0 comments on commit fb93acf

Please sign in to comment.