Skip to content

Commit

Permalink
JHP-82: Enables support for JupyterHub named servers allowing users t…
Browse files Browse the repository at this point in the history
…o start multiple Jupyter notebooks or Dashboards from the XNAT UI

JHP-82: Un-hide named server REST APIs.

JHP-82: Starts named servers on JH. Add preferences to set max number of named JH servers.

JHP-82: Fix user activity table to support JH named servers.

JHP-82: Use the event tracking id for server names.

JHP-82: Fix unit tests

JHP-82: Delete user options after server shutdown.

JHP-82: Need to add the remove flag when stopping named servers

JHP-82: Update name of named server setting for clarity.

JHP-82: Display notice about running servers when starting notebooks or dashboards

JHP-82: Add to CHANGELOG.MD

JHP-82: Nit tweak warning messages.
  • Loading branch information
andylassiter committed Jun 3, 2024
1 parent a8cbf42 commit 77aa827
Show file tree
Hide file tree
Showing 12 changed files with 253 additions and 93 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- [JHP-82]: Enables support for JupyterHub named servers. This feature allows users to start multiple Jupyter notebooks
or Dashboards from the XNAT UI. A new preference has been added to the plugin to limit the number of named
servers a user can start. The default is 1. May require browser cache to be cleared.

### Changed

- [JHP-89]: Changed the action labels to `Start Jupyter Notebook` and `Start Jupyter Dashboard` for clarity and
Expand Down Expand Up @@ -65,5 +71,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[JHP-73]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-73
[JHP-74]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-74
[JHP-77]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-77
[JHP-82]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-82
[JHP-83]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-83
[JHP-88]: https://radiologics.atlassian.net/jira/software/c/projects/JHP/issues/JHP-88
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.springframework.web.client.RestTemplate;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Expand Down Expand Up @@ -246,10 +247,12 @@ public void stopServer(String username, String servername) {

RestTemplate restTemplate = new RestTemplate();

Map<String, Boolean> requestBody = Collections.singletonMap("remove", true);

MultiValueMap<String, String> headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, "token " + jupyterHubApiToken);
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
HttpEntity<String> request = new HttpEntity<>(null, headers);
HttpEntity<Map<String, Boolean>> request = new HttpEntity<>(requestBody, headers);

try {
ResponseEntity<String> response = restTemplate.exchange(serverUrl(username, servername),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class JupyterHubPreferences extends AbstractPreferenceBean {
public static final String RESOURCE_SPEC_MEM_RESERVATION_PREF_ID = "resourceSpecMemReservation";
public static final String INACTIVITY_TIMEOUT_PREF_ID = "inactivityTimeout";
public static final String MAX_SERVER_LIFETIME_PREF_ID = "maxServerLifetime";
public static final String MAX_NAMED_SERVERS_PREF_ID = "maxNamedServers";
public static final String SHARED_PROJECT_STRING = "jupyter-notebooks";


Expand Down Expand Up @@ -336,6 +337,23 @@ public void setMaxServerLifetime(final long maxServerLifetime) {
}
}

@NrgPreference(defaultValue = "1")
public int getMaxNamedServers() {
return getIntegerValue(MAX_NAMED_SERVERS_PREF_ID);
}

public void setMaxNamedServers(final int maxNamedServers) {
try {
if (maxNamedServers < 1) {
throw new IllegalArgumentException("Max named servers must be at least 1.");
}

setIntegerValue(maxNamedServers, MAX_NAMED_SERVERS_PREF_ID);
} catch (InvalidPreferenceName e) {
log.error("Invalid preference name 'maxNamedServers': something is very wrong here.", e);
}
}

@NrgPreference(defaultValue = "false")
public boolean getAllUsersCanStartJupyter() {
return getBooleanValue(ALL_USERS_JUPYTER);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.web.bind.annotation.RequestMethod.*;

@SuppressWarnings("DefaultAnnotationParam")
@Api("JupyterHub Plugin API")
@XapiRestController
@RequestMapping("/jupyterhub")
Expand Down Expand Up @@ -133,7 +132,7 @@ public Server getServer(@ApiParam(value = "username", required = true) @PathVari
return jupyterHubService.getServer(getUserI(username)).orElse(null);
}

@ApiOperation(value = "Get Jupyter Server details for a user.", hidden = true)
@ApiOperation(value = "Get Jupyter Server details for a user.")
@ApiResponses({@ApiResponse(code = 200, message = "Server found."),
@ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."),
@ApiResponse(code = 403, message = "Not authorized."),
Expand Down Expand Up @@ -165,8 +164,7 @@ public void startServer(@ApiParam(value = "username", required = true) @PathVari


@ApiOperation(value = "Starts a Jupyter server for the user",
notes = "Use the Event Tracking API to track progress.",
hidden = true)
notes = "Use the Event Tracking API to track progress.")
@ApiResponses({@ApiResponse(code = 200, message = "Jupyter server successfully started"),
@ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."),
@ApiResponse(code = 403, message = "Not authorized."),
Expand Down Expand Up @@ -202,8 +200,7 @@ public XnatUserOptions getUserOptions(@ApiParam(value = "username", required = t
}

@ApiOperation(value = "Returns the last known user options for the named server",
response = XnatUserOptions.class,
hidden = true)
response = XnatUserOptions.class)
@ApiResponses({@ApiResponse(code = 200, message = "Successfully retrieved user options."),
@ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."),
@ApiResponse(code = 403, message = "Not authorized to access site configuration properties."),
Expand Down Expand Up @@ -247,8 +244,7 @@ public void stopServer(@ApiParam(value = "username", required = true) @PathVaria
}

@ApiOperation(value = "Stops a users named Jupyter server",
notes = "Use the Event Tracking API to track progress.",
hidden = true)
notes = "Use the Event Tracking API to track progress.")
@ApiResponses({@ApiResponse(code = 200, message = "Jupyter server successfully stopped."),
@ApiResponse(code = 401, message = "Must be authenticated to access the XNAT REST API."),
@ApiResponse(code = 403, message = "Not authorized."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ public Map<String, Object> getSpecifiedPreference(@ApiParam(value = "The Jupyter
value = jupyterHubPreferences.getMaxServerLifetime();
break;
}
case (JupyterHubPreferences.MAX_NAMED_SERVERS_PREF_ID): {
value = jupyterHubPreferences.getMaxNamedServers();
break;
}
case (JupyterHubPreferences.ALL_USERS_JUPYTER): {
value = jupyterHubPreferences.getAllUsersCanStartJupyter();
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ public interface UserOptionsService {
Optional<XnatUserOptions> retrieveUserOptions(UserI user);
Optional<XnatUserOptions> retrieveUserOptions(UserI user, String servername);
void storeUserOptions(UserI user, String servername, String xsiType, String id, String projectId, Long computeEnvironmentConfigId, Long hardwareConfigId, Long dashboardConfigId, String eventTrackingId) throws BaseXnatExperimentdata.UnknownPrimaryProjectException, DBPoolException, SQLException, InvalidArchiveStructure, IOException;
void removeUserOptions(UserI user, String servername);

}
Original file line number Diff line number Diff line change
Expand Up @@ -241,17 +241,15 @@ public void startServer(final UserI user, final ServerStartRequest startRequest)
JupyterServerEventI.Operation.Start, 0,
"Checking for existing Jupyter servers."));

boolean hasRunningServer = jupyterHubClient.getUser(user.getUsername())
.orElseGet(() -> createUser(user))
.getServers()
.containsKey(servername);
if (hasRunningServer) {
eventService.triggerEvent(JupyterServerEvent.failed(eventTrackingId,
user.getID(), xsiType, itemId,
int maxNamedServers = jupyterHubPreferences.getMaxNamedServers();
boolean hasMaxNamedServers = jupyterHubClient.getUser(user.getUsername())
.orElseGet(() -> createUser(user))
.getServers()
.size() >= maxNamedServers;
if (hasMaxNamedServers) {
eventService.triggerEvent(JupyterServerEvent.failed(eventTrackingId, user.getID(), xsiType, itemId,
JupyterServerEventI.Operation.Start,
"Failed to launch " + application + ". " +
"There is already a dashboard or Jupyter notebook running. " +
"Please stop the running dashboard or notebook before starting a new one."));
"Failed to launch " + application + ". Maximum number of running Jupyter servers reached."));
return;
}

Expand Down Expand Up @@ -448,6 +446,8 @@ public void stopServer(final UserI user, final String servername, String eventTr

if (!server.isPresent()) {
log.info("Jupyter server stopped for user {}", user.getUsername());
log.debug("Removing user options for user {} and server {}", user.getUsername(), servername);
userOptionsService.removeUserOptions(user, servername);
eventService.triggerEvent(JupyterServerEvent.completed(eventTrackingId, user.getID(),
JupyterServerEventI.Operation.Stop,
"Jupyter Server Stopped."));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,13 @@ public void storeUserOptions(UserI user, String servername, String xsiType, Stri
userOptionsEntityService.createOrUpdate(userOptionsEntity);
}

@Override
public void removeUserOptions(UserI user, String servername) {
log.debug("Removing user options for user '{}' server '{}'", user.getUsername(), servername);
Optional<UserOptionsEntity> userOptionsEntity = userOptionsEntityService.find(user.getID(), servername);
userOptionsEntity.ifPresent(userOptionsEntityService::delete);
}

/**
* Translate paths within the archive to paths within the docker container
* @param path the path to translate, must be the archive path or a subdirectory of the archive path
Expand Down
Loading

0 comments on commit 77aa827

Please sign in to comment.