Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support named servers #63

Merged
merged 2 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading