Skip to content

Commit

Permalink
Merge pull request #73 from lsst-sqre/tickets/DM-43393
Browse files Browse the repository at this point in the history
DM-43393: Cast job_try to str when making SlackTextField
  • Loading branch information
jonathansick authored Mar 21, 2024
2 parents 40b4714 + 0ad583f commit b0804a5
Show file tree
Hide file tree
Showing 10 changed files with 89 additions and 146 deletions.
10 changes: 5 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ jobs:
timeout-minutes: 5

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Run pre-commit
uses: pre-commit/action@v3.0.0
uses: pre-commit/action@v3.0.1

test:
runs-on: ubuntu-latest
Expand All @@ -43,7 +43,7 @@ jobs:
- "3.12"

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Run tox
uses: lsst-sqre/run-tox@v1
Expand All @@ -67,7 +67,7 @@ jobs:
|| startsWith(github.head_ref, 'tickets/'))
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependencies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
timeout-minutes: 10

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Run neophile
uses: lsst-sqre/run-neophile@v1
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.12"

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/periodic-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- "3.12"

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

# Use the oldest supported version of Python to update dependencies,
# not the matrixed Python version, since this accurately reflects
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

<!-- scriv-insert-here -->

<a id='changelog-0.9.1'></a>
## 0.9.1 (2024-03-21)

### Bug fixes

- Fix Slack error messaging in the `nbexec` worker function.
- Extract and use the actual XSRF token when communicating with the Hub and Lab.

<a id='changelog-0.9.0'></a>

## 0.9.0 (2024-03-13)
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# - Runs a non-root user.
# - Sets up the entrypoint and port.

FROM python:3.12.1-slim-bullseye as base-image
FROM python:3.12.2-slim-bullseye as base-image

# Update system packages
COPY scripts/install-base-packages.sh .
Expand Down
137 changes: 60 additions & 77 deletions src/noteburst/jupyterclient/jupyterlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@
import contextlib
import datetime
import json
import string
from collections.abc import AsyncGenerator, AsyncIterator
from dataclasses import dataclass
from random import SystemRandom
from typing import Annotated, Any, Self
from typing import Annotated, Any
from urllib.parse import urljoin, urlparse
from uuid import uuid4

import httpx
import websockets
from httpx import Cookies
from pydantic import BaseModel, Field
from structlog import BoundLogger
from websockets.client import WebSocketClientProtocol
Expand Down Expand Up @@ -422,83 +421,35 @@ def __init__(
str(noteburst_config.environment_url), self.config.url_prefix
)

self._http_client: httpx.AsyncClient | None = None
self._lab_controller_client: LabControllerClient | None = None
self._common_headers: dict[str, str] # set and reset in the context

@property
def http_client(self) -> httpx.AsyncClient:
"""The HTTPX client instance associated with the Jupyter session.."""
if self._http_client is None:
self._open_clients()
if self._http_client is None:
raise RuntimeError("http_client is not set")
return self._http_client

@property
def lab_controller(self) -> LabControllerClient:
"""The Jupyter Lab Controller client, only available in the
JupyterClient context.
"""
if self._lab_controller_client is None:
self._open_clients()
if self._lab_controller_client is None:
raise RuntimeError("LabControllerClient is not set set up")
return self._lab_controller_client

async def __aenter__(self) -> Self:
self._open_clients()
return self

def _open_clients(self) -> None:
if (self._http_client is not None) or (
self._lab_controller_client is not None
):
raise RuntimeError(
"JupyterClient is already open. Call close() before "
"re-opening?"
)

alphabet = string.ascii_uppercase + string.digits
xsrf_token = "".join(SystemRandom().choices(alphabet, k=16))
headers = {
"x-xsrftoken": xsrf_token,
self._headers = {
"Authorization": f"Bearer {self.user.token}",
}
self._common_headers = headers
cookies = {"_xsrf": xsrf_token}

self._http_client = httpx.AsyncClient(
headers=headers,
cookies=cookies,
self.http_client = httpx.AsyncClient(
headers=self._headers,
follow_redirects=True,
timeout=30.0, # default is 5, but Hub can be slow
timeout=30,
)
self._lab_controller_client: LabControllerClient | None = None
self._hub_xsrf: str | None = None
self._lab_xsrf: str | None = None

@property
def lab_controller(self) -> LabControllerClient:
if self._lab_controller_client:
return self._lab_controller_client

# Create a LabController client
# We also send the XSRF token to Lab Controller because of how we're
# sharing the session, but that shouldn't matter.
self._lab_controller_client = LabControllerClient(
http_client=self.http_client,
token=noteburst_config.gafaelfawr_token.get_secret_value(),
url_prefix=noteburst_config.nublado_controller_path_prefix,
)

async def __aexit__(self, *exc_info: object) -> None:
await self.close()
return self._lab_controller_client

async def close(self) -> None:
"""Manually close the client.
Do not use this method for manually closing the Jupyter client when
using JupyterClient as an async context manager. The client is
closed automatically.
"""
"""Close the client."""
self._lab_controller_client = None

await self.http_client.aclose()
self._http_client = None
self._common_headers = {}

def url_for(self, path: str) -> str:
"""Create a URL relative to the jupyter_url."""
Expand All @@ -515,14 +466,17 @@ def url_for_websocket(self, path: str) -> str:
async def log_into_hub(self) -> None:
"""Log into JupyterHub or raise a JupyterError."""
self.logger.debug("Logging into JupyterHub")
r = await self.http_client.get(
self.url_for("hub/login"), follow_redirects=False
)
r = await self.http_client.get(self.url_for("hub/home"))
# JupyterHub returns a 302 redirect to the login page on success,
# but we don't want to follow that redirect. This request is just
# to set cookies.
if r.status_code >= 400:
raise JupyterError.from_response(self.user.username, r)
cookies = Cookies()
cookies.extract_cookies(r)
xsrf = cookies.get("_xsrf")
if xsrf:
self._hub_xsrf = xsrf

async def log_into_lab(self) -> None:
"""Log into JupyterLab or raise a JupyterError."""
Expand All @@ -532,6 +486,11 @@ async def log_into_lab(self) -> None:
)
if r.status_code != 200:
raise JupyterError.from_response(self.user.username, r)
cookies = Cookies()
cookies.extract_cookies(r)
xsrf = cookies.get("_xsrf")
if xsrf:
self._lab_xsrf = xsrf

async def spawn_lab(self) -> JupyterImage:
"""Spawn a JupyterLab pod."""
Expand All @@ -540,13 +499,16 @@ async def spawn_lab(self) -> JupyterImage:
# Retrieving the spawn page before POSTing to it appears to trigger
# some necessary internal state construction (and also more accurately
# simulates a user interaction). See DM-23864.
_ = await self.http_client.get(spawn_url)
headers = dict(self._headers)
if self._hub_xsrf:
headers["X-XSRFToken"] = self._hub_xsrf
_ = await self.http_client.get(spawn_url, headers=headers)

# POST the options form to the spawn page. This should redirect to
# the spawn-pending page, which will return a 200.
image = await self._get_spawn_image()
data = self._build_jupyter_spawn_form(image)
r = await self.http_client.post(spawn_url, data=data)
r = await self.http_client.post(spawn_url, data=data, headers=headers)
if r.status_code != 200:
raise JupyterError.from_response(self.user.username, r)

Expand All @@ -565,6 +527,8 @@ async def spawn_progress(self) -> AsyncIterator[SpawnProgressMessage]:
)
referer_url = self.url_for("hub/home")
headers = {"Referer": referer_url}
if self._hub_xsrf:
headers["X-XSRFToken"] = self._hub_xsrf
while True:
async with self.http_client.stream(
"GET", progress_url, headers=headers
Expand Down Expand Up @@ -626,6 +590,8 @@ async def stop_lab(self) -> None:
server_url = self.url_for(f"hub/api/users/{user}/server")
referer_url = self.url_for("hub/home")
headers = {"Referer": referer_url}
if self._hub_xsrf:
headers["X-XSRFToken"] = self._hub_xsrf
r = await self.http_client.delete(server_url, headers=headers)
if r.status_code not in [200, 202, 204]:
raise JupyterError.from_response(self.user.username, r)
Expand All @@ -642,6 +608,8 @@ async def is_lab_stopped(self, *, final: bool = False) -> bool:
user_url = self.url_for(f"hub/api/users/{self.user.username}")
referer_url = self.url_for("hub/home")
headers = {"Referer": referer_url}
if self._hub_xsrf:
headers["X-XSRFToken"] = self._hub_xsrf
r = await self.http_client.get(user_url, headers=headers)
if r.status_code != 200:
raise JupyterError.from_response(self.user.username, r)
Expand Down Expand Up @@ -669,7 +637,12 @@ async def open_lab_session(
"path": notebook_name if notebook_name else uuid4().hex,
"type": session_type,
}
r = await self.http_client.post(session_url, json=body)
headers = {}
if self._lab_xsrf:
headers["X-XSRFToken"] = self._lab_xsrf
r = await self.http_client.post(
session_url, json=body, headers=headers
)
if r.status_code != 201:
raise JupyterError.from_response(self.user.username, r)
session_resource = r.json()
Expand All @@ -685,10 +658,11 @@ async def open_lab_session(
# Generate a mock request and copy its headers / cookies over to the
# websocket connection.
mock_request = self.http_client.build_request("GET", http_channels_uri)
copied_headers = ["x-xsrftoken", "authorization", "cookie"]
websocket_headers = {
header: mock_request.headers[header] for header in copied_headers
headers = {
h: mock_request.headers[h] for h in ("authorization", "cookie")
}
if self._lab_xsrf:
headers["X-XSRFToken"] = self._lab_xsrf

session_id: str | None = None # will be set if a session is opened
self.logger.debug("Trying to create websocket connection")
Expand All @@ -699,7 +673,7 @@ async def open_lab_session(
# long lived clients
# https://websockets.readthedocs.io/en/stable/reference/client.html#using-a-connection
async with websockets.connect(
wss_channels_uri, extra_headers=websocket_headers
wss_channels_uri, extra_headers=headers
) as websocket:
self.logger.info("Created websocket connection")
jupyter_lab_session = JupyterLabSession(
Expand All @@ -720,7 +694,9 @@ async def open_lab_session(
session_id_url = self.url_for(
f"user/{self.user.username}/api/sessions/{session_id}"
)
r = await self.http_client.delete(session_id_url)
r = await self.http_client.delete(
session_id_url, headers=headers
)
if r.status_code != 204:
raise JupyterError.from_response(self.user.username, r)

Expand All @@ -747,10 +723,14 @@ async def execute_notebook(
Notebook execution extension.
"""
exec_url = self.url_for(f"user/{self.user.username}/rubin/execution")
headers = {}
if self._lab_xsrf:
headers["X-XSRFToken"] = self._lab_xsrf
try:
r = await self.http_client.post(
exec_url,
content=json.dumps(notebook).encode("utf-8"),
headers=headers,
)
except httpx.HTTPError as e:
# This often occurs from timeouts, so we want to convert the
Expand All @@ -777,7 +757,10 @@ async def get_jupyterlab_env(self) -> dict[str, Any]:
environment_url = self.url_for(
f"user/{self.user.username}/rubin/environment"
)
r = await self.http_client.get(environment_url)
headers = {}
if self._lab_xsrf:
headers["X-XSRFToken"] = self._lab_xsrf
r = await self.http_client.get(environment_url, headers=headers)
if r.status_code != 200:
raise JupyterError.from_response(self.user.username, r)
return r.json()
10 changes: 8 additions & 2 deletions src/noteburst/jupyterclient/labcontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ class LabControllerClient:
"""

def __init__(
self, *, http_client: httpx.AsyncClient, token: str, url_prefix: str
self,
*,
http_client: httpx.AsyncClient,
token: str,
url_prefix: str,
) -> None:
self._http_client = http_client
self._token = token
Expand Down Expand Up @@ -204,7 +208,9 @@ async def get_by_reference(self, reference: str) -> JupyterImage:
return image

async def _get_images(self) -> LabControllerImages:
headers = {"Authorization": f"bearer {self._token}"}
headers = {
"Authorization": f"bearer {self._token}",
}
url = urljoin(
str(config.environment_url),
f"{self._url_prefix}/spawner/v1/images",
Expand Down
Loading

0 comments on commit b0804a5

Please sign in to comment.