Skip to content

Commit

Permalink
Support named servers (#63)
Browse files Browse the repository at this point in the history
* Add support for named servers

* Update @vscode/test-web
  • Loading branch information
DonJayamanne authored Jul 30, 2024
1 parent bf0f879 commit 232f5b2
Show file tree
Hide file tree
Showing 13 changed files with 1,305 additions and 528 deletions.
1,330 changes: 862 additions & 468 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jupyter-hub",
"displayName": "JupyterHub",
"version": "2024.4.100",
"version": "2024.5.100",
"description": "Support for connecting to Jupyter Hub in VS Code along with the Jupyter Extension",
"publisher": "ms-toolsai",
"preview": true,
Expand Down Expand Up @@ -68,6 +68,7 @@
"enum": [
"off",
"error",
"warn",
"debug"
],
"description": "%jupyterHub.configuration.jupyterHub.log.description%"
Expand Down Expand Up @@ -101,7 +102,7 @@
"open-in-browser": "vscode-test-web --extensionDevelopmentPath=. ./tmp"
},
"dependencies": {
"@jupyterlab/services": "^7.0.5",
"@jupyterlab/services": "^7.2.4",
"@vscode/extension-telemetry": "^0.7.7",
"buffer": "^6.0.3",
"events": "^3.3.0",
Expand All @@ -127,7 +128,7 @@
"@vscode/dts": "^0.4.0",
"@vscode/jupyter-extension": "^0.0.7",
"@vscode/test-electron": "^2.3.4",
"@vscode/test-web": "^0.0.53",
"@vscode/test-web": "^0.0.56",
"assert": "^2.1.0",
"chai": "^4.3.8",
"chai-as-promised": "^7.1.1",
Expand Down
49 changes: 49 additions & 0 deletions src/common/inputCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,55 @@ export class WorkflowInputCapture {
token.onCancellationRequested(() => reject(new CancellationError()), this, this.disposables);
});
}
public async pickValue<T extends QuickPickItem>(
options: {
title: string;
placeholder?: string;
validationMessage?: string;
quickPickItems: T[];
},
token: CancellationToken
) {
return new Promise<T | undefined>((resolve, reject) => {
const input = window.createQuickPick<T>();
this.disposables.push(new Disposable(() => input.hide()));
this.disposables.push(input);
input.ignoreFocusOut = true;
input.title = options.title;
input.ignoreFocusOut = true;
input.placeholder = options.placeholder || '';
input.buttons = [QuickInputButtons.Back];
input.items = options.quickPickItems;
input.canSelectMany = false;
input.show();
input.onDidHide(() => reject(new CancellationError()), this, this.disposables);
input.onDidTriggerButton(
(e) => {
if (e === QuickInputButtons.Back) {
resolve(undefined);
}
},
this,
this.disposables
);
input.onDidAccept(
async () => {
// After this we always end up doing some async stuff,
// or display a new quick pick or ui.
// Hence mark this as busy until we dismiss this UI.
input.busy = true;
if (input.selectedItems.length === 1) {
resolve(input.selectedItems[0]);
} else {
resolve(undefined);
}
},
this,
this.disposables
);
token.onCancellationRequested(() => reject(new CancellationError()), this, this.disposables);
});
}
}

/**
Expand Down
11 changes: 9 additions & 2 deletions src/common/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { disposableStore } from './lifecycle';

export const outputChannel = disposableStore.add(window.createOutputChannel(Localized.OutputChannelName, 'log'));

let loggingLevel: 'error' | 'debug' | 'off' = workspace.getConfiguration('jupyterhub').get('log', 'error');
let loggingLevel: 'error' | 'debug' | 'off' | 'warn' = workspace.getConfiguration('jupyterhub').get('log', 'error');

disposableStore.add(
workspace.onDidChangeConfiguration((e) => {
Expand All @@ -23,6 +23,13 @@ disposableStore.add(
})
);

export function traceWarn(..._args: unknown[]): void {
if (loggingLevel === 'off') {
return;
}
logMessage('warn', ..._args);
}

export function traceError(..._args: unknown[]): void {
if (loggingLevel === 'off') {
return;
Expand All @@ -37,7 +44,7 @@ export function traceDebug(_message: string, ..._args: unknown[]): void {
logMessage('debug', ..._args);
}

function logMessage(level: 'error' | 'debug', ...data: unknown[]) {
function logMessage(level: 'error' | 'debug' | 'warn', ...data: unknown[]) {
outputChannel.appendLine(`${getTimeForLogging()} [${level}] ${formatErrors(...data).join(' ')}`);
}

Expand Down
2 changes: 2 additions & 0 deletions src/common/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ interface JupyterHubUrlNotAdded {
| 'Get Username'
| 'Get Password'
| 'Verify Connection'
| 'Server Selector'
| 'Get Display Name'
| 'After';
}
Expand Down Expand Up @@ -176,6 +177,7 @@ export function sendJupyterHubUrlNotAdded(
| 'Get Username'
| 'Get Password'
| 'Verify Connection'
| 'Server Selector'
| 'Get Display Name'
| 'After'
) {
Expand Down
149 changes: 119 additions & 30 deletions src/jupyterHubApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,78 @@
import { CancellationToken, workspace } from 'vscode';
import { SimpleFetch } from './common/request';
import { ServerConnection } from '@jupyterlab/services';
import { traceDebug, traceError } from './common/logging';
import { traceDebug, traceError, traceWarn } from './common/logging';
import { appendUrlPath } from './utils';
import { noop } from './common/utils';
import { trackUsageOfOldApiGeneration } from './common/telemetry';

export namespace ApiTypes {
/**
* https://jupyterhub.readthedocs.io/en/stable/reference/rest-api.html#operation/get-user
*/
export interface UserInfo {
server: string;
/**
* The user's notebook server's base URL, if running; null if not.
*/
server?: string;
last_activity: Date;
roles: string[];
groups: string[];
name: string;
admin: boolean;
pending: null | 'spawn';
servers: Record<
string,
{
name: string;
last_activity: Date;
started: Date;
pending: null | 'spawn';
ready: boolean;
stopped: boolean;
url: string;
user_options: {};
progress_url: string;
}
>;
session_id: string;
scopes: string[];
pending?: null | 'spawn' | 'stop';
/**
* The servers for this user. By default: only includes active servers.
* Changed in 3.0: if ?include_stopped_servers parameter is specified, stopped servers will be included as well.
*/
servers?: Record<string, ServerInfo>;
}
export interface ServerInfo {
/**
* The server's name.
* The user's default server has an empty name
*/
name: string;
/**
* UTC timestamp last-seen activity on this server.
*/
last_activity: Date;
/**
* UTC timestamp when the server was last started.
*/
started?: Date;
/**
* The currently pending action, if any.
* A server is not ready if an action is pending.
*/
pending?: null | 'spawn' | 'stop';
/**
* Whether the server is ready for traffic.
* Will always be false when any transition is pending.
*/
ready: boolean;
/**
* Whether the server is stopped.
* Added in JupyterHub 3.0,
* and only useful when using the ?include_stopped_servers request parameter.
* Now that stopped servers may be included (since JupyterHub 3.0),
* this is the simplest way to select stopped servers.
* Always equivalent to not (ready or pending).
*/
stopped: boolean;
/**
* The URL path where the server can be accessed (typically /user/:name/:server.name/).
* Will be a full URL if subdomains are configured.
*/
url: string;
/**
* User specified options for the user's spawned instance of a single-user server.
*/
user_options: {};
/**
* The URL path for an event-stream to retrieve events during a spawn.
*/
progress_url: string;
}
}

Expand Down Expand Up @@ -153,10 +195,14 @@ export async function getUserInfo(
username: string,
token: string,
fetch: SimpleFetch,
cancellationToken: CancellationToken
cancellationToken: CancellationToken,
includeStoppedServers?: boolean
): Promise<ApiTypes.UserInfo> {
traceDebug(`Getting user info for user ${baseUrl}, token length = ${token.length} && ${token.trim().length}`);
const url = appendUrlPath(baseUrl, `hub/api/users/${username}`);
const path = includeStoppedServers
? `hub/api/users/${username}?include_stopped_servers`
: `hub/api/users/${username}`;
const url = appendUrlPath(baseUrl, path);
const headers = { Authorization: `token ${token}` };
const response = await fetch.send(url, { method: 'GET', headers }, cancellationToken);
if (response.status === 200) {
Expand All @@ -168,29 +214,71 @@ export async function getUserInfo(
export async function getUserJupyterUrl(
baseUrl: string,
username: string,
serverName: string | undefined,
token: string,
fetch: SimpleFetch,
cancelToken: CancellationToken
) {
let usersJupyterUrl = await getUserInfo(baseUrl, username, token, fetch, cancelToken)
.then((info) => appendUrlPath(baseUrl, info.server))
.catch((ex) => {
traceError(`Failed to get the user Jupyter Url`, ex);
// If we have a server name, then also get a list of the stopped servers.
// Possible the server has been stopped.
const includeStoppedServers = !!serverName;
const info = await getUserInfo(baseUrl, username, token, fetch, cancelToken, includeStoppedServers);
if (serverName) {
// Find the server in the list
const server = (info.servers || {})[serverName];
if (server?.url) {
return appendUrlPath(baseUrl, server.url);
}
const servers = Object.keys(info.servers || {});
traceError(
`Failed to get the user Jupyter Url for ${serverName} existing servers include ${JSON.stringify(info)}`
);
throw new Error(
`Named Jupyter Server '${serverName}' not found, existing servers include ${servers.join(', ')}`
);
} else {
const defaultServer = (info.servers || {})['']?.url || info.server;
if (defaultServer) {
return appendUrlPath(baseUrl, defaultServer);
}
traceError(
`Failed to get the user Jupyter Url as there is no default server for the user ${JSON.stringify(info)}`
);
return appendUrlPath(baseUrl, `user/${username}/`);
}
}

export async function listServers(
baseUrl: string,
username: string,
token: string,
fetch: SimpleFetch,
cancelToken: CancellationToken
) {
try {
const info = await getUserInfo(baseUrl, username, token, fetch, cancelToken, true).catch((ex) => {
traceWarn(`Failed to get user info with stopped servers, defaulting without`, ex);
return getUserInfo(baseUrl, username, token, fetch, cancelToken);
});
if (!usersJupyterUrl) {
usersJupyterUrl = appendUrlPath(baseUrl, `user/${username}/`);

return Object.values(info.servers || {});
} catch (ex) {
traceError(`Failed to get a list of servers for the user ${username}`, ex);
return [];
}
return usersJupyterUrl;
}

export async function startServer(
baseUrl: string,
username: string,
serverName: string | undefined,
token: string,
fetch: SimpleFetch,
cancellationToken: CancellationToken
): Promise<void> {
const url = appendUrlPath(baseUrl, `hub/api/users/${username}/server`);
const url = serverName
? appendUrlPath(baseUrl, `hub/api/users/${username}/servers/${encodeURIComponent(serverName)}`)
: appendUrlPath(baseUrl, `hub/api/users/${username}/server`);
const headers = { Authorization: `token ${token}` };
const response = await fetch.send(url, { method: 'POST', headers }, cancellationToken);
if (response.status === 201 || response.status === 202) {
Expand All @@ -213,14 +301,15 @@ async function getResponseErrorMessageToThrowOrLog(message: string, response?: R

export async function createServerConnectSettings(
baseUrl: string,
serverName: string | undefined,
authInfo: {
username: string;
token: string;
},
fetch: SimpleFetch,
cancelToken: CancellationToken
): Promise<ServerConnection.ISettings> {
baseUrl = await getUserJupyterUrl(baseUrl, authInfo.username, authInfo.token, fetch, cancelToken);
baseUrl = await getUserJupyterUrl(baseUrl, authInfo.username, serverName, authInfo.token, fetch, cancelToken);
let serverSettings: Partial<ServerConnection.ISettings> = {
baseUrl,
appUrl: '',
Expand Down
17 changes: 15 additions & 2 deletions src/jupyterIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ export class JupyterServerIntegration implements JupyterServerProvider, JupyterS
{
baseUrl: serverInfo.baseUrl,
displayName: serverInfo.displayName,
id: serverInfo.id
id: serverInfo.id,
serverName: serverInfo.serverName
},
{
password: authInfo.password || '',
Expand All @@ -215,7 +216,7 @@ export class JupyterServerIntegration implements JupyterServerProvider, JupyterS
}
}

// Ensure the server is running.
// Validate the uri and auth infor.
// Else nothing will work when attempting to connect to this server from Jupyter Extension.
await this.jupyterConnectionValidator
.validateJupyterUri(
Expand All @@ -225,10 +226,22 @@ export class JupyterServerIntegration implements JupyterServerProvider, JupyterS
cancelToken
)
.catch(noop);
// Ensure the server is running.
// Else nothing will work when attempting to connect to this server from Jupyter Extension.
await this.jupyterConnectionValidator
.ensureServerIsRunning(
serverInfo.baseUrl,
serverInfo.serverName,
{ username: authInfo.username, password: authInfo.password, token: result.token },
this.newAuthenticator,
cancelToken
)
.catch(noop);

const rawBaseUrl = await getUserJupyterUrl(
serverInfo.baseUrl,
authInfo.username || '',
serverInfo.serverName,
authInfo.token,
this.fetch,
cancelToken
Expand Down
Loading

0 comments on commit 232f5b2

Please sign in to comment.