|
| 1 | +# Migrating Workbench Images to Kubernetes Gateway API |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +When migrating from OpenShift Route + oauth-proxy to Kubernetes Gateway API + kube-rbac-proxy, workbench images require nginx configuration updates to properly handle path-based routing. |
| 6 | + |
| 7 | +## The Core Requirement |
| 8 | + |
| 9 | +**Your workbench image must serve all content from the base path `${NB_PREFIX}`.** |
| 10 | + |
| 11 | +Any call from a browser to a different path, for example `/index.html`, `/api/my-endpoint`, or simply `/`, won't be routed to the workbench container. This is because the routing, handled by the Gateway API, is path-based, using the same value as the environment variable `NB_PREFIX` that is injected into the workbench at runtime. |
| 12 | + |
| 13 | +Example `NB_PREFIX`: `/notebook/<namespace>/<workbench-name>` |
| 14 | + |
| 15 | +## Key Architectural Difference |
| 16 | + |
| 17 | +### OpenShift Route (Old) |
| 18 | +``` |
| 19 | +External: /notebook/user/workbench/app/ |
| 20 | + ↓ |
| 21 | +Route strips prefix |
| 22 | + ↓ |
| 23 | +Container receives: /app/ |
| 24 | +``` |
| 25 | + |
| 26 | +**Important**: The prefix stripping isn't automatic - it requires implementation: |
| 27 | +- **nginx** strips the prefix via rewrite rules |
| 28 | +- **Catch-all redirects** like `location / { return 302 /app; }` |
| 29 | + |
| 30 | +Both approaches work because the Route **forwards all traffic** to the pod regardless of path. |
| 31 | + |
| 32 | +### Gateway API (New) |
| 33 | +``` |
| 34 | +External: /notebook/user/workbench/app/ |
| 35 | + ↓ |
| 36 | +Gateway preserves full path (path-based routing) |
| 37 | + ↓ |
| 38 | +Container receives: /notebook/user/workbench/app/ |
| 39 | +``` |
| 40 | + |
| 41 | +**Critical Difference**: Gateway API uses **path-based routing**. Only requests matching the configured path prefix are forwarded to the pod. |
| 42 | + |
| 43 | +### Why Old Approaches Fail with Gateway API |
| 44 | + |
| 45 | +``` |
| 46 | +App redirects: /notebook/user/workbench/app → /app |
| 47 | + ↓ |
| 48 | +Browser follows redirect to: /app |
| 49 | + ↓ |
| 50 | +Gateway routing rule: /notebook/user/workbench/** (doesn't match /app!) |
| 51 | + ↓ |
| 52 | +Pod receives NO traffic → 404 or routing failure |
| 53 | +``` |
| 54 | + |
| 55 | +**The Problem**: If your application redirects to paths outside `${NB_PREFIX}`, the Gateway cannot route those requests back to your pod. The path-based matching at the Gateway level requires all traffic to stay within the configured prefix. |
| 56 | + |
| 57 | +**Critical Change**: Your application (or reverse proxy) must handle the **full path** including the prefix and never redirect outside of it. |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## Part 1: For All Workbenches - General Requirements |
| 62 | + |
| 63 | +These requirements apply **regardless of whether you use nginx or application-level path handling**. |
| 64 | + |
| 65 | +### 1. Health Check Endpoints |
| 66 | + |
| 67 | +Your workbench **must** respond to health checks at: |
| 68 | + |
| 69 | +``` |
| 70 | +GET /{NB_PREFIX}/api |
| 71 | +``` |
| 72 | + |
| 73 | +This endpoint must return an HTTP 200 status for probes to succeed. |
| 74 | + |
| 75 | +**Example for Python Flask**: |
| 76 | +```python |
| 77 | +from flask import Flask |
| 78 | +import os |
| 79 | + |
| 80 | +app = Flask(__name__) |
| 81 | +nb_prefix = os.getenv('NB_PREFIX', '') |
| 82 | + |
| 83 | +@app.route(f'{nb_prefix}/api') |
| 84 | +def health_check(): |
| 85 | + return {'status': 'healthy'}, 200 |
| 86 | + |
| 87 | +@app.route(f'{nb_prefix}/api/kernels') |
| 88 | +def kernels(): |
| 89 | + # Handle culler endpoint |
| 90 | + return {'kernels': []}, 200 |
| 91 | + |
| 92 | +@app.route(f'{nb_prefix}/api/terminals') |
| 93 | +def terminals(): |
| 94 | + # Handle culler endpoint |
| 95 | + return {'terminals': []}, 200 |
| 96 | +``` |
| 97 | + |
| 98 | +**Example for Node.js Express**: |
| 99 | +```javascript |
| 100 | +const express = require('express'); |
| 101 | +const app = express(); |
| 102 | +const nbPrefix = process.env.NB_PREFIX || ''; |
| 103 | + |
| 104 | +app.get(`${nbPrefix}/api`, (req, res) => { |
| 105 | + res.json({ status: 'healthy' }); |
| 106 | +}); |
| 107 | + |
| 108 | +app.get(`${nbPrefix}/api/kernels`, (req, res) => { |
| 109 | + res.json({ kernels: [] }); |
| 110 | +}); |
| 111 | + |
| 112 | +app.get(`${nbPrefix}/api/terminals`, (req, res) => { |
| 113 | + res.json({ terminals: [] }); |
| 114 | +}); |
| 115 | +``` |
| 116 | + |
| 117 | +### 2. Culler Endpoints |
| 118 | + |
| 119 | +If your workbench supports culling idle workbenches, you must handle: |
| 120 | + |
| 121 | +``` |
| 122 | +GET /{NB_PREFIX}/api/kernels |
| 123 | +GET /{NB_PREFIX}/api/terminals |
| 124 | +``` |
| 125 | + |
| 126 | +These should return information about active kernels/terminals, or empty arrays if none exist. |
| 127 | + |
| 128 | +### 3. Use Relative URLs in Your Application |
| 129 | + |
| 130 | +**Critical**: Your application must generate relative URLs, not absolute ones. |
| 131 | + |
| 132 | +```html |
| 133 | +<!-- ❌ BAD - Hardcoded absolute path --> |
| 134 | +<a href="/menu1">Menu 1</a> |
| 135 | +<script src="/static/app.js"></script> |
| 136 | +<img src="/images/logo.png" /> |
| 137 | + |
| 138 | +<!-- ✅ GOOD - Relative URLs --> |
| 139 | +<a href="menu1">Menu 1</a> |
| 140 | +<script src="static/app.js"></script> |
| 141 | +<img src="images/logo.png" /> |
| 142 | + |
| 143 | +<!-- ✅ ALSO GOOD - Framework-generated URLs with base path --> |
| 144 | +<a href="{{ url_for('menu1') }}">Menu 1</a> |
| 145 | +``` |
| 146 | + |
| 147 | +**Why**: Hardcoded absolute paths like `/menu1` will not include the `{NB_PREFIX}`, causing 404 errors. Relative URLs or framework-generated URLs will correctly resolve to `/{NB_PREFIX}/menu1`. |
| 148 | + |
| 149 | +### 4. Configure Your Application's Base Path |
| 150 | + |
| 151 | +If your framework supports it, configure the base path using the `NB_PREFIX` environment variable: |
| 152 | + |
| 153 | +**FastAPI**: |
| 154 | +```python |
| 155 | +from fastapi import FastAPI |
| 156 | +import os |
| 157 | + |
| 158 | +app = FastAPI(root_path=os.getenv('NB_PREFIX', '')) |
| 159 | +``` |
| 160 | + |
| 161 | +**Flask**: |
| 162 | +```python |
| 163 | +from flask import Flask |
| 164 | +import os |
| 165 | + |
| 166 | +app = Flask(__name__) |
| 167 | +app.config['APPLICATION_ROOT'] = os.getenv('NB_PREFIX', '') |
| 168 | +``` |
| 169 | + |
| 170 | +**Express.js**: |
| 171 | +```javascript |
| 172 | +const express = require('express'); |
| 173 | +const app = express(); |
| 174 | +const nbPrefix = process.env.NB_PREFIX || ''; |
| 175 | + |
| 176 | +// Mount all routes under the prefix |
| 177 | +const router = express.Router(); |
| 178 | +// ... define routes on router ... |
| 179 | +app.use(nbPrefix, router); |
| 180 | +``` |
| 181 | + |
| 182 | +**Streamlit**: |
| 183 | +```toml |
| 184 | +# .streamlit/config.toml |
| 185 | +[server] |
| 186 | +baseUrlPath = "/notebook/namespace/workbench" # Set via NB_PREFIX |
| 187 | +``` |
| 188 | + |
| 189 | +### 5. Limitations: Applications with Hardcoded Absolute Paths |
| 190 | + |
| 191 | +**If your application has hardcoded absolute paths that cannot be changed**, migration becomes very difficult: |
| 192 | + |
| 193 | +```javascript |
| 194 | +// ❌ This cannot work with Gateway API unless rewritten |
| 195 | +const menuUrl = "/menu1"; // Hardcoded absolute path |
| 196 | +fetch(menuUrl).then(...); |
| 197 | +``` |
| 198 | + |
| 199 | +**Solutions**: |
| 200 | +1. **Modify the application** - Change to relative URLs or configurable base path (preferred) |
| 201 | +2. **Use nginx with URL rewriting** - nginx can intercept and rewrite some URLs, but this is limited |
| 202 | +3. **HTML/JS post-processing** - Intercept responses and rewrite URLs (complex, not recommended) |
| 203 | + |
| 204 | +**Warning**: nginx can rewrite URLs in redirects and some headers, but it **cannot** rewrite URLs embedded in HTML/JavaScript content without complex content manipulation, which is error-prone and slow. |
| 205 | + |
| 206 | +--- |
| 207 | + |
| 208 | +## Part 2: For nginx-based Workbenches - Reverse Proxy Configuration |
| 209 | + |
| 210 | +**Use this section if** your application does not support base path configuration and you need nginx to handle the path translation. |
| 211 | + |
| 212 | +### Required nginx Changes |
| 213 | + |
| 214 | +### 1. Remove Problematic Location Blocks |
| 215 | + |
| 216 | +**REMOVE** any overly broad location blocks that cause infinite redirects: |
| 217 | + |
| 218 | +```nginx |
| 219 | +# ❌ REMOVE THIS - Too broad, causes infinite loops |
| 220 | +location ${NB_PREFIX}/ { |
| 221 | + return 302 $custom_scheme://$http_host/app/; |
| 222 | +} |
| 223 | +``` |
| 224 | + |
| 225 | +**Why**: This matches ALL paths under the prefix, including your application endpoint itself (e.g., `/notebook/user/workbench/app/`), creating redirect loops. |
| 226 | + |
| 227 | +### 2. Update Redirects to Preserve NB_PREFIX |
| 228 | + |
| 229 | +**All redirects must include `${NB_PREFIX}`** to keep requests within the Gateway route: |
| 230 | + |
| 231 | +```nginx |
| 232 | +# ❌ BAD - Strips prefix |
| 233 | +location = ${NB_PREFIX} { |
| 234 | + return 302 $custom_scheme://$http_host/myapp/; |
| 235 | +} |
| 236 | +
|
| 237 | +# ✅ GOOD - Preserves prefix |
| 238 | +location ${NB_PREFIX} { |
| 239 | + return 302 $custom_scheme://$http_host${NB_PREFIX}/myapp/; |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +**Note**: Use `location ${NB_PREFIX}` (without `=`) to handle both with and without trailing slash. |
| 244 | + |
| 245 | +### 3. Add Prefix-Aware Proxy Location |
| 246 | + |
| 247 | +**Add a location block** that matches the full prefixed path and strips the prefix before proxying: |
| 248 | + |
| 249 | +```nginx |
| 250 | +location ${NB_PREFIX}/myapp/ { |
| 251 | + # Strip the prefix before proxying to backend |
| 252 | + rewrite ^${NB_PREFIX}/myapp/(.*)$ /$1 break; |
| 253 | + |
| 254 | + # Proxy to your application |
| 255 | + proxy_pass http://localhost:8080/; |
| 256 | + proxy_http_version 1.1; |
| 257 | + |
| 258 | + # Essential for WebSocket support |
| 259 | + proxy_set_header Upgrade $http_upgrade; |
| 260 | + proxy_set_header Connection $connection_upgrade; |
| 261 | + |
| 262 | + # Long timeout for interactive sessions |
| 263 | + proxy_read_timeout 20d; |
| 264 | + |
| 265 | + # Pass through important headers |
| 266 | + proxy_set_header Host $http_host; |
| 267 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| 268 | + proxy_set_header X-Forwarded-Proto $custom_scheme; |
| 269 | +} |
| 270 | +``` |
| 271 | + |
| 272 | +### 4. Update Health Check Endpoints |
| 273 | + |
| 274 | +Health checks must also preserve the prefix: |
| 275 | + |
| 276 | +```nginx |
| 277 | +# Health check endpoint |
| 278 | +location = ${NB_PREFIX}/api { |
| 279 | + return 302 ${NB_PREFIX}/myapp/healthz/; |
| 280 | + access_log off; |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +### 5. Add Wildcard server_name Fallback |
| 285 | + |
| 286 | +Gateway API uses different hostnames than OpenShift Routes. Add fallback logic: |
| 287 | + |
| 288 | +```bash |
| 289 | +# In run-nginx.sh or startup script |
| 290 | +export BASE_URL=$(extract_base_url_from_notebook_args) |
| 291 | + |
| 292 | +# If BASE_URL is empty or invalid, use wildcard server_name |
| 293 | +if [ -z "$BASE_URL" ] || [ "$BASE_URL" = "$(echo $NB_PREFIX | awk -F/ '{ print $4"-"$3 }')" ]; then |
| 294 | + export BASE_URL="_" |
| 295 | +fi |
| 296 | +``` |
| 297 | + |
| 298 | +This sets `server_name _;` which accepts requests from any hostname. |
| 299 | + |
| 300 | +### 6. Update kube-rbac-proxy Configuration |
| 301 | + |
| 302 | +Remove trailing slashes from upstream URLs in pod/statefulset specs: |
| 303 | + |
| 304 | +```yaml |
| 305 | +# ❌ BAD |
| 306 | +args: |
| 307 | + - '--upstream=http://127.0.0.1:8888/' |
| 308 | + |
| 309 | +# ✅ GOOD |
| 310 | +args: |
| 311 | + - '--upstream=http://127.0.0.1:8888' |
| 312 | +``` |
| 313 | +
|
| 314 | +## HTTPRoute Configuration |
| 315 | +
|
| 316 | +Ensure your HTTPRoute matches the full prefix path: |
| 317 | +
|
| 318 | +```yaml |
| 319 | +apiVersion: gateway.networking.k8s.io/v1 |
| 320 | +kind: HTTPRoute |
| 321 | +metadata: |
| 322 | + name: my-workbench |
| 323 | + namespace: <user-namespace> |
| 324 | +spec: |
| 325 | + parentRefs: |
| 326 | + - group: gateway.networking.k8s.io |
| 327 | + kind: Gateway |
| 328 | + name: data-science-gateway |
| 329 | + namespace: openshift-ingress |
| 330 | + rules: |
| 331 | + - backendRefs: |
| 332 | + - kind: Service |
| 333 | + name: my-workbench-rbac |
| 334 | + port: 8443 |
| 335 | + weight: 1 |
| 336 | + matches: |
| 337 | + - path: |
| 338 | + type: PathPrefix |
| 339 | + value: /notebook/<namespace>/<workbench-name> |
| 340 | +``` |
| 341 | +
|
| 342 | +**Important**: The `value` must match the `NB_PREFIX` environment variable set in the pod. |
| 343 | + |
| 344 | +## Reference Implementation |
| 345 | + |
| 346 | +See these files for complete examples: |
| 347 | + |
| 348 | +### Code-Server |
| 349 | +- **nginx config**: `codeserver/ubi9-python-3.12/nginx/serverconf/proxy.conf.template_nbprefix` |
| 350 | +- **startup script**: `codeserver/ubi9-python-3.12/run-nginx.sh` |
| 351 | + |
| 352 | +### RStudio |
| 353 | +- **nginx config**: `rstudio/c9s-python-3.11/nginx/serverconf/proxy.conf.template_nbprefix` |
| 354 | +- **startup script**: `rstudio/c9s-python-3.11/run-nginx.sh` |
| 355 | + |
| 356 | +## Understanding nginx Location Matching |
| 357 | + |
| 358 | +nginx location blocks have different matching priorities: |
| 359 | + |
| 360 | +```nginx |
| 361 | +# 1. Exact match (highest priority) |
| 362 | +location = /exact/path { |
| 363 | + # Only matches /exact/path (no trailing slash) |
| 364 | +} |
| 365 | +
|
| 366 | +# 2. Prefix match (evaluated in order of length) |
| 367 | +location /prefix { |
| 368 | + # Matches /prefix, /prefix/, /prefix/anything |
| 369 | +} |
| 370 | +
|
| 371 | +# 3. Regex match (not covered here) |
| 372 | +``` |
| 373 | + |
| 374 | +For Gateway API, you need: |
| 375 | + |
| 376 | +```nginx |
| 377 | +# Redirect root to app |
| 378 | +location ${NB_PREFIX} { |
| 379 | + return 302 $custom_scheme://$http_host${NB_PREFIX}/myapp/; |
| 380 | +} |
| 381 | +
|
| 382 | +# Proxy app traffic (longer prefix wins) |
| 383 | +location ${NB_PREFIX}/myapp/ { |
| 384 | + proxy_pass http://localhost:8080/; |
| 385 | +} |
| 386 | +``` |
| 387 | + |
| 388 | +Request `/notebook/ns/wb` → matches first location → redirects |
| 389 | +Request `/notebook/ns/wb/myapp/` → matches second location (longer) → proxies |
0 commit comments