Skip to content

Commit f855918

Browse files
authored
Spawn custom images via kubespawner profiles (#634)
* Spawn custom images via kubespawner profiles * load profile options * download to path /tmp * bump proxy version * install jhub-app proxy irrespective * fix unit test * bump proxy version * Fix docker image user input box * polulate server image * bump proxy version and fix keep alive
1 parent ee29a84 commit f855918

File tree

10 files changed

+187
-53
lines changed

10 files changed

+187
-53
lines changed

jhub_apps/config_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
# jhub-app-proxy configuration constants
12-
DEFAULT_JHUB_APP_PROXY_VERSION = "v0.2.2-rc2"
12+
DEFAULT_JHUB_APP_PROXY_VERSION = "v0.2.2-rc5"
1313

1414

1515
class PydanticModelTrait(TraitType):

jhub_apps/service/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class JHubAppConfig(BaseModel):
8181
class UserOptions(JHubAppConfig):
8282
conda_env: typing.Optional[str] = str()
8383
profile: typing.Optional[str] = str()
84+
profile_image: typing.Optional[str] = str()
8485
share_with: typing.Optional[SharePermissions] = None
8586
jhub_app: bool
8687

jhub_apps/spawner/spawner_creation.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def get_proxy_version(config, app_env=None):
4343

4444
def wrap_command_with_proxy_installer(cmd_list, proxy_version):
4545
"""
46-
Wraps a command list in a bash script that installs jhub-app-proxy if needed.
46+
Wraps a command list in a bash script that installs jhub-app-proxy.
4747
4848
Args:
4949
cmd_list: List of command arguments (e.g., ['jhub-app-proxy', '--authtype=oauth', ...])
@@ -56,15 +56,13 @@ def wrap_command_with_proxy_installer(cmd_list, proxy_version):
5656
cmd_str = ' '.join(shlex.quote(str(arg)) for arg in cmd_list)
5757

5858
install_script = f'''
59-
# Ensure ~/.local/bin is in PATH first
60-
export PATH="$HOME/.local/bin:$PATH"
59+
# Ensure ~/.local/bin and /tmp/.local/bin are in PATH
60+
export PATH="$HOME/.local/bin:/tmp/.local/bin:$PATH"
6161
62-
# Install jhub-app-proxy if not present
63-
if ! command -v jhub-app-proxy &> /dev/null; then
64-
echo "jhub-app-proxy not found, installing..."
65-
echo "Running: curl -fsSL {JHUB_APP_PROXY_INSTALL_URL} | bash -s -- -v {proxy_version}"
66-
curl -fsSL {JHUB_APP_PROXY_INSTALL_URL} | bash -s -- -v {proxy_version}
67-
fi
62+
# Install jhub-app-proxy (overrides if already present)
63+
echo "Installing jhub-app-proxy version {proxy_version}..."
64+
echo "Running: curl -fsSL {JHUB_APP_PROXY_INSTALL_URL} | bash -s -- -v {proxy_version} -d /tmp/.local/bin"
65+
curl -fsSL {JHUB_APP_PROXY_INSTALL_URL} | bash -s -- -v {proxy_version} -d /tmp/.local/bin
6866
6967
# Execute the original command
7068
echo "Running command: {cmd_str}"
@@ -158,9 +156,22 @@ def get_env(self):
158156
env["BOKEH_RESOURCES"] = "cdn"
159157
return env
160158

159+
async def load_user_options(self):
160+
"""Load user options and apply profile_image override"""
161+
await super().load_user_options()
162+
163+
# Apply profile_image as a kubespawner override
164+
# This needs to happen after the profile is loaded
165+
# but before the pod is created
166+
profile_image = self.user_options.get("profile_image")
167+
if profile_image:
168+
logger.info(f"Overriding profile image with: {profile_image}")
169+
self.image = profile_image
170+
161171
async def start(self):
162172
logger.info("Starting spawner process")
163173
await self._get_user_auth_state()
174+
164175
framework = self.user_options.get("framework")
165176
if self.user_options.get("jhub_app"):
166177
# JupyterLab has built-in JupyterHub auth, so use authtype=none
@@ -181,13 +192,14 @@ async def start(self):
181192
env = self.user_options.get("env", {})
182193
if self.user_options.get("keep_alive") or (env and env.get("JH_APPS_KEEP_ALIVE")):
183194
logger.info(
184-
"Flag set to force keep alive, will not be deleted by idle culler",
195+
"Flag set to keep alive, will not be deleted by idle culler",
185196
app=self.user_options.get("display_name"),
186197
framework=self.user_options.get("framework")
187198
)
188-
base_cmd.append("--force-alive")
199+
base_cmd.append("--keep-alive=true")
189200
else:
190-
base_cmd.append("--no-force-alive")
201+
# Default behavior: report actual activity (allow idle culling)
202+
base_cmd.append("--keep-alive=false")
191203

192204
# Add git repository arguments to base_cmd (before -- separator)
193205
repository = self.user_options.get("repository")

jhub_apps/static/js/index.js

Lines changed: 38 additions & 38 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

jhub_apps/tests/tests_unit/test_command_template.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def test_wrap_command_with_proxy_installer():
2727
assert wrapped[0] == "/bin/bash"
2828
assert wrapped[1] == "-c"
2929
assert "jhub-app-proxy" in wrapped[2]
30-
assert "command -v jhub-app-proxy" in wrapped[2]
30+
assert "Installing jhub-app-proxy" in wrapped[2]
3131
assert "curl -fsSL" in wrapped[2]
3232
assert "install.sh" in wrapped[2]
3333
assert "export PATH=" in wrapped[2]

k3s-dev/config/02-profiles.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"slug": "small",
1313
"default": True,
1414
"kubespawner_override": {
15+
"image": "quay.io/nebari/nebari-jupyterlab:main",
1516
"cpu_limit": 1,
1617
"mem_limit": "1G",
1718
"cpu_guarantee": 0.1,
@@ -23,6 +24,7 @@
2324
"description": "2 CPU / 2 GB RAM - Good for Panel/Streamlit apps",
2425
"slug": "medium",
2526
"kubespawner_override": {
27+
"image": "quay.io/nebari/nebari-jupyterlab:main",
2628
"cpu_limit": 2,
2729
"mem_limit": "2G",
2830
"cpu_guarantee": 0.5,
@@ -34,6 +36,7 @@
3436
"description": "4 CPU / 4 GB RAM - For resource-intensive apps",
3537
"slug": "large",
3638
"kubespawner_override": {
39+
"image": "quay.io/nebari/nebari-jupyterlab:main",
3740
"cpu_limit": 4,
3841
"mem_limit": "4G",
3942
"cpu_guarantee": 1,
@@ -45,4 +48,5 @@
4548
print("✅ KubeSpawner profiles loaded")
4649
print(f" - {len(c.KubeSpawner.profile_list)} profiles available")
4750
for profile in c.KubeSpawner.profile_list:
48-
print(f" • {profile['display_name']} ({profile['slug']})")
51+
image = profile.get('kubespawner_override', {}).get('image', 'default')
52+
print(f" • {profile['display_name']} ({profile['slug']}) - Image: {image}")

ui/src/components/app-form/app-form.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export const AppForm = ({
111111
conda_env: '',
112112
custom_command: '',
113113
profile: '',
114+
profile_image: '',
114115
is_public: false,
115116
keep_alive: false,
116117
share_with: {
@@ -134,6 +135,7 @@ export const AppForm = ({
134135
conda_env: '',
135136
custom_command: '',
136137
profile: '',
138+
profile_image: '',
137139
is_public: false,
138140
keep_alive: false,
139141
share_with: {
@@ -157,6 +159,7 @@ export const AppForm = ({
157159
conda_env: '',
158160
custom_command: '',
159161
profile: '',
162+
profile_image: '',
160163
is_public: false,
161164
keep_alive: false,
162165
});
@@ -439,6 +442,7 @@ export const AppForm = ({
439442
conda_env,
440443
custom_command,
441444
profile,
445+
profile_image,
442446
}) => {
443447
setIsProcessing(true);
444448
const displayName = getFriendlyDisplayName(display_name);
@@ -454,6 +458,7 @@ export const AppForm = ({
454458
env: getFriendlyEnvironmentVariables(variables),
455459
custom_command,
456460
profile,
461+
profile_image,
457462
is_public: isPublic,
458463
share_with: {
459464
users: currentUserPermissions,
@@ -629,6 +634,7 @@ export const AppForm = ({
629634
conda_env: currentFormInput.conda_env || '',
630635
custom_command: currentFormInput.custom_command || '',
631636
profile: currentFormInput.profile || '',
637+
profile_image: currentFormInput.profile_image || '',
632638
});
633639
setIsPublic(currentFormInput.is_public);
634640
setKeepAlive(currentFormInput.keep_alive);

ui/src/pages/server-types/server-types.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Radio,
1010
RadioGroup,
1111
Stack,
12+
TextField,
1213
Typography,
1314
} from '@mui/material';
1415
import { AppProfileProps, AppQueryUpdateProps } from '@src/types/api';
@@ -61,6 +62,9 @@ export const ServerTypes = (): React.ReactElement => {
6162
const [selectedServerType, setSelectedServerType] = React.useState<string>(
6263
currentFormInput?.profile || '',
6364
);
65+
const [profileImages, setProfileImages] = React.useState<
66+
Record<string, string>
67+
>({});
6468
const [isHeadless] = useRecoilState<boolean>(defaultIsHeadless);
6569
const id = searchParams.get('id');
6670

@@ -89,6 +93,20 @@ export const ServerTypes = (): React.ReactElement => {
8993
setCurrentFormInput({
9094
...currentFormInput,
9195
profile: slug,
96+
profile_image: profileImages[slug] || '',
97+
});
98+
}
99+
};
100+
101+
const handleImageChange = (slug: string, image: string) => {
102+
setProfileImages({
103+
...profileImages,
104+
[slug]: image,
105+
});
106+
if (selectedServerType === slug && currentFormInput) {
107+
setCurrentFormInput({
108+
...currentFormInput,
109+
profile_image: image,
92110
});
93111
}
94112
};
@@ -108,6 +126,7 @@ export const ServerTypes = (): React.ReactElement => {
108126
env: getFriendlyEnvironmentVariables(currentFormInput?.env),
109127
custom_command: currentFormInput?.custom_command || '',
110128
profile: currentFormInput?.profile || '',
129+
profile_image: currentFormInput?.profile_image || '',
111130
public: currentFormInput?.is_public || false,
112131
share_with: {
113132
users: currentFormInput?.share_with?.users || [],
@@ -213,6 +232,70 @@ export const ServerTypes = (): React.ReactElement => {
213232
}
214233
}, [error, setCurrentNotification]);
215234

235+
// Effect 1: Initialize profile images from serverTypes with default images
236+
useEffect(() => {
237+
if (!serverTypes || serverTypes.length === 0) {
238+
return;
239+
}
240+
241+
const images: Record<string, string> = {};
242+
let defaultProfileSlug = '';
243+
244+
serverTypes.forEach((type, index) => {
245+
const defaultImage = type.kubespawner_override?.image || '';
246+
images[type.slug] = defaultImage;
247+
248+
// Find the default profile or use the first one
249+
if (type.default || (!defaultProfileSlug && index === 0)) {
250+
defaultProfileSlug = type.slug;
251+
}
252+
});
253+
254+
setProfileImages(images);
255+
256+
// Auto-select default profile if no profile is currently selected
257+
if (!currentFormInput?.profile && defaultProfileSlug && currentFormInput) {
258+
setSelectedServerType(defaultProfileSlug);
259+
setCurrentFormInput({
260+
...currentFormInput,
261+
profile: defaultProfileSlug,
262+
profile_image: images[defaultProfileSlug] || '',
263+
});
264+
}
265+
}, [serverTypes]);
266+
267+
// Effect 2: Populate custom profile image when coming from edit mode
268+
// This runs when the profile changes (e.g., when navigating to this page with edit data)
269+
useEffect(() => {
270+
if (
271+
!serverTypes ||
272+
serverTypes.length === 0 ||
273+
!currentFormInput?.profile
274+
) {
275+
return;
276+
}
277+
278+
const profileImage = currentFormInput.profile_image;
279+
if (!profileImage) {
280+
return;
281+
}
282+
283+
// Find the matching server type to get its default image
284+
const matchingType = serverTypes.find(
285+
(type) => type.slug === currentFormInput.profile,
286+
);
287+
const defaultImage = matchingType?.kubespawner_override?.image || '';
288+
289+
// Only update if the profile_image is different from the default
290+
// (meaning the user had customized it previously)
291+
if (profileImage !== defaultImage && currentFormInput.profile) {
292+
setProfileImages((prev) => ({
293+
...prev,
294+
[currentFormInput.profile as string]: profileImage,
295+
}));
296+
}
297+
}, [serverTypes, currentFormInput?.profile]);
298+
216299
return (
217300
<Box className="container">
218301
<Stack>
@@ -281,6 +364,24 @@ export const ServerTypes = (): React.ReactElement => {
281364
label={type.display_name}
282365
/>
283366
<p>{type.description}</p>
367+
{type.kubespawner_override?.image && (
368+
<Box
369+
sx={{ mt: 2 }}
370+
onClick={(e) => e.stopPropagation()}
371+
>
372+
<TextField
373+
fullWidth
374+
size="small"
375+
label="Image"
376+
value={profileImages[type.slug] || ''}
377+
onChange={(e) =>
378+
handleImageChange(type.slug, e.target.value)
379+
}
380+
variant="outlined"
381+
placeholder={type.kubespawner_override.image}
382+
/>
383+
</Box>
384+
)}
284385
</CardContent>
285386
</Card>
286387
))}

ui/src/types/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface UserOptions {
1818
custom_command: string;
1919
conda_env: string;
2020
profile: string;
21+
profile_image?: string;
2122
public: boolean;
2223
share_with: SharePermissions;
2324
keep_alive: boolean;
@@ -67,4 +68,12 @@ export interface AppProfileProps {
6768
display_name: string;
6869
slug: string;
6970
description: string;
71+
default?: boolean;
72+
kubespawner_override?: {
73+
image?: string;
74+
cpu_limit?: number;
75+
cpu_guarantee?: number;
76+
mem_limit?: string;
77+
mem_guarantee?: string;
78+
};
7079
}

ui/src/types/form.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface AppFormInput {
1717
keep_alive: boolean;
1818
jhub_app: boolean;
1919
profile?: string;
20+
profile_image?: string;
2021
thumbnail?: string;
2122
share_with: SharePermissions;
2223
repository?: {

0 commit comments

Comments
 (0)