Skip to content

Commit aa73c68

Browse files
committed
Improve local auth/spawn behavior and make launcher sizing labels configurable
1 parent ebfac74 commit aa73c68

4 files changed

Lines changed: 83 additions & 8 deletions

File tree

config/jupyterhub/00-gateway-auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async def authenticate(self, handler, data=None):
6262
}
6363

6464

65-
if get_config("custom.external-url", ""):
65+
if bool(get_config("custom.gateway-auth-enabled", False)):
6666
c.JupyterHub.authenticator_class = EnvoyOIDCAuthenticator
6767
# All users who pass Keycloak auth at the gateway are allowed
6868
c.Authenticator.allow_all = True

config/jupyterhub/01-spawner.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
# Using fixed /home/jovyan mount path because {username} expands differently in
1010
# KubeSpawner (escaped slug) vs base Spawner traits like notebook_dir (raw username),
1111
# causing a path mismatch when usernames contain special characters (e.g. emails).
12-
# Each user still gets an isolated PVC via claim-{username}.
12+
# Named-server PVC behavior differs across spawner wrappers. For local reliability,
13+
# force one home PVC per user (shared across that user's named servers): claim-{username}.
14+
# This avoids pending spawns like: persistentvolumeclaim "claim-<user>" not found.
15+
c.KubeSpawner.pvc_name_template = "claim-{username}"
1316
c.KubeSpawner.storage_pvc_ensure = True
1417
c.KubeSpawner.storage_capacity = "10Gi"
1518
c.KubeSpawner.storage_access_modes = ["ReadWriteOnce"]

config/jupyterhub/02-jhub-apps.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
# bind_url must include the real external hostname so JupyterHub constructs
1111
# correct OAuth redirect URLs for internal services like jhub-apps.
1212
# See: nebari's 02-spawner.py for the same pattern.
13-
domain = get_config("custom.external-url", "")
13+
domain = str(get_config("custom.external-url", "") or "").strip()
1414
if domain:
15-
c.JupyterHub.bind_url = f"https://{domain}"
15+
if domain.startswith("http://") or domain.startswith("https://"):
16+
c.JupyterHub.bind_url = domain
17+
else:
18+
scheme = str(get_config("custom.external-url-scheme", "https") or "https").strip()
19+
c.JupyterHub.bind_url = f"{scheme}://{domain}"
1620
else:
1721
c.JupyterHub.bind_url = "http://0.0.0.0:8000"
1822
c.JupyterHub.default_url = "/hub/home"
@@ -32,3 +36,34 @@
3236

3337
# Install jhub-apps (sets up service, roles, etc.)
3438
c = install_jhub_apps(c, spawner_to_subclass=KubeSpawner)
39+
40+
# Ensure all authenticated Hub users can access launcher services.
41+
# This prevents intermittent "You do not have permission to access JupyterHub service japps"
42+
# errors in local setups with mixed/stale cookies.
43+
existing_roles = list(getattr(c.JupyterHub, "load_roles", []) or [])
44+
extra_scopes = {
45+
"self",
46+
"access:services!service=japps",
47+
"access:services!service=pi-launcher",
48+
}
49+
50+
# De-duplicate any pre-existing "user" role definitions and re-add exactly one.
51+
merged_user_scopes = set()
52+
filtered_roles = []
53+
for role in existing_roles:
54+
if isinstance(role, dict) and role.get("name") == "user":
55+
scopes = role.get("scopes")
56+
if isinstance(scopes, list):
57+
merged_user_scopes.update(scopes)
58+
continue
59+
filtered_roles.append(role)
60+
61+
merged_user_scopes.update(extra_scopes)
62+
filtered_roles.append(
63+
{
64+
"name": "user",
65+
"scopes": sorted(merged_user_scopes),
66+
}
67+
)
68+
69+
c.JupyterHub.load_roles = filtered_roles

config/jupyterhub/05-pi-launcher.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
"medium": "pi-medium",
5858
"large": "pi-large",
5959
}
60+
PROFILE_UI_SPECS = {
61+
"small": os.environ.get("PI_PROFILE_SMALL_SPEC", "2 cpu / 8G RAM"),
62+
"medium": os.environ.get("PI_PROFILE_MEDIUM_SPEC", "4 cpu / 16G RAM"),
63+
"large": os.environ.get("PI_PROFILE_LARGE_SPEC", "8 cpu / 32G RAM"),
64+
}
6065
6166
def hub_request(method: str, path: str, **kwargs):
6267
headers = kwargs.pop("headers", {})
@@ -832,17 +837,17 @@ def get(self):
832837
<form method="post" action="{SERVICE_PREFIX}/launch">
833838
<input type="hidden" name="_xsrf" value="{xsrf_token}" />
834839
<input type="hidden" name="size" value="small" />
835-
<button class="btn" type="submit">Small<small>2 cpu / 8 GB ram</small></button>
840+
<button class="btn" type="submit">Small<small>{html.escape(str(PROFILE_UI_SPECS.get('small', '')))}</small></button>
836841
</form>
837842
<form method="post" action="{SERVICE_PREFIX}/launch">
838843
<input type="hidden" name="_xsrf" value="{xsrf_token}" />
839844
<input type="hidden" name="size" value="medium" />
840-
<button class="btn" type="submit">Medium<small>4 cpu / 16 GB ram</small></button>
845+
<button class="btn" type="submit">Medium<small>{html.escape(str(PROFILE_UI_SPECS.get('medium', '')))}</small></button>
841846
</form>
842847
<form method="post" action="{SERVICE_PREFIX}/launch">
843848
<input type="hidden" name="_xsrf" value="{xsrf_token}" />
844849
<input type="hidden" name="size" value="large" />
845-
<button class="btn" type="submit">Large<small>8 cpu / 32 GB ram</small></button>
850+
<button class="btn" type="submit">Large<small>{html.escape(str(PROFILE_UI_SPECS.get('large', '')))}</small></button>
846851
</form>
847852
</div>
848853
<div class="row">
@@ -1590,10 +1595,39 @@ def make_app():
15901595
pi_sharing_namespace = str(z2jh.get_config("custom.pi-sharing-namespace", "") or "").strip()
15911596
pi_share_session_max_bytes = str(z2jh.get_config("custom.pi-share-session-max-bytes", 1048576))
15921597
pi_coding_agent_dir = str(z2jh.get_config("custom.pi-coding-agent-dir", "/tmp/pi-agent") or "/tmp/pi-agent").strip()
1598+
1599+
pi_profiles_cfg = z2jh.get_config("custom.pi-profiles", {}) or {}
1600+
1601+
def _format_cpu_value(value):
1602+
if value is None:
1603+
return ""
1604+
try:
1605+
f = float(value)
1606+
if f.is_integer():
1607+
return str(int(f))
1608+
except Exception:
1609+
pass
1610+
return str(value)
1611+
1612+
def _profile_spec_text(size_key, default_text):
1613+
if isinstance(pi_profiles_cfg, dict):
1614+
spec = pi_profiles_cfg.get(size_key)
1615+
if isinstance(spec, dict):
1616+
cpu = _format_cpu_value(spec.get("cpu_limit"))
1617+
mem = str(spec.get("mem_limit") or "").strip()
1618+
if cpu and mem:
1619+
return f"{cpu} cpu / {mem} RAM"
1620+
return default_text
1621+
1622+
pi_profile_small_spec = _profile_spec_text("small", "2 cpu / 8G RAM")
1623+
pi_profile_medium_spec = _profile_spec_text("medium", "4 cpu / 16G RAM")
1624+
pi_profile_large_spec = _profile_spec_text("large", "8 cpu / 32G RAM")
1625+
15931626
public_host = z2jh.get_config("custom.external-url") or ""
15941627
public_host = str(public_host).strip().rstrip("/")
15951628
if public_host and not public_host.startswith(("http://", "https://")):
1596-
public_host = f"https://{public_host}"
1629+
public_scheme = str(z2jh.get_config("custom.external-url-scheme", "https") or "https").strip()
1630+
public_host = f"{public_scheme}://{public_host}"
15971631
pi_launcher_oauth_redirect_uri = f"{public_host}/services/{pi_launcher_service_name}/oauth_callback"
15981632

15991633
launcher_env = {
@@ -1605,6 +1639,9 @@ def make_app():
16051639
"PI_LIVE_SHARE_ENABLED": pi_live_share_enabled,
16061640
"PI_SHARE_SESSION_MAX_BYTES": pi_share_session_max_bytes,
16071641
"PI_CODING_AGENT_DIR": pi_coding_agent_dir,
1642+
"PI_PROFILE_SMALL_SPEC": pi_profile_small_spec,
1643+
"PI_PROFILE_MEDIUM_SPEC": pi_profile_medium_spec,
1644+
"PI_PROFILE_LARGE_SPEC": pi_profile_large_spec,
16081645
}
16091646
if pi_sharing_namespace:
16101647
launcher_env["PI_SHARING_NAMESPACE"] = pi_sharing_namespace

0 commit comments

Comments
 (0)