Skip to content

Commit 99e9398

Browse files
authored
Merge pull request #4 from nebari-dev/aktech/add-root-path-subst
Add root path substitution
2 parents 4269e32 + 1e284a5 commit 99e9398

File tree

3 files changed

+192
-1
lines changed

3 files changed

+192
-1
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,32 @@ jhub-app-proxy --port 8000 --destport 8050 --authtype none --log-format pretty \
106106
- `--destport` - Internal subprocess port (0 = random, default: 0)
107107
- `--authtype` - Authentication type: `oauth`, `none` (default: `oauth`)
108108

109+
### Template Substitution
110+
111+
JHub Apps Proxy supports template placeholders in your application commands that are automatically replaced at runtime:
112+
113+
#### Port Templating
114+
Use `{port}` in your command and it will be replaced with the actual port allocated for internal routing:
115+
116+
```bash
117+
jhub-app-proxy --port 8000 --destport 8501 --authtype none \
118+
-- streamlit run app.py --server.port {port}
119+
```
120+
121+
#### Root Path Templating
122+
Use `{root_path}` when your application needs to know its deployment prefix. This is especially useful when apps are deployed at dynamic URLs that aren't known in advance.
123+
124+
The `{root_path}` placeholder is automatically replaced with the appropriate path constructed from the `JUPYTERHUB_SERVICE_PREFIX` environment variable (prepended with `/hub`).
125+
126+
**Example:** If `JUPYTERHUB_SERVICE_PREFIX=/user/[email protected]/myapp/`, then `{root_path}` becomes `/hub/user/[email protected]/myapp`
127+
128+
```bash
129+
jhub-app-proxy --port 8000 --destport 8050 --authtype none \
130+
-- panel serve app.py --port {port} --prefix {root_path}
131+
```
132+
133+
This eliminates the need to hardcode deployment paths in your application commands, making them portable across different JupyterHub deployments.
134+
109135
### Process Management
110136
- `--conda-env` - Conda environment to activate before running command
111137
- `--workdir` - Working directory for the process

pkg/command/builder.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,44 @@ func (b *Builder) GetCondaWarning() string {
5555
return b.condaWarning
5656
}
5757

58+
// GetRootPath constructs the root path from JUPYTERHUB_SERVICE_PREFIX
59+
// by prepending /hub and ensuring proper path formatting (no double slashes, proper trailing slash handling)
60+
func GetRootPath() string {
61+
servicePrefix := os.Getenv("JUPYTERHUB_SERVICE_PREFIX")
62+
if servicePrefix == "" {
63+
return ""
64+
}
65+
66+
// Ensure service prefix starts with /
67+
if !strings.HasPrefix(servicePrefix, "/") {
68+
servicePrefix = "/" + servicePrefix
69+
}
70+
71+
// Remove trailing slash from service prefix for consistent joining
72+
servicePrefix = strings.TrimSuffix(servicePrefix, "/")
73+
74+
// Construct root path: /hub + service_prefix
75+
rootPath := "/hub" + servicePrefix
76+
77+
return rootPath
78+
}
79+
5880
// SubstitutePort replaces jhsingle-native-proxy style placeholders in command arguments
59-
// Handles: {port} → actual port, {-} → -, {--} → --, and strips surrounding quotes
81+
// Handles: {port} → actual port, {root_path} → JupyterHub root path, {-} → -, {--} → --, and strips surrounding quotes
6082
func SubstitutePort(command []string, allocatedPort int) []string {
6183
result := make([]string, len(command))
6284
portStr := fmt.Sprintf("%d", allocatedPort)
85+
rootPath := GetRootPath()
6386

6487
for i, arg := range command {
6588
processed := arg
6689

6790
// Replace port placeholder
6891
processed = strings.ReplaceAll(processed, "{port}", portStr)
6992

93+
// Replace root_path placeholder
94+
processed = strings.ReplaceAll(processed, "{root_path}", rootPath)
95+
7096
// Replace dash placeholders (jhsingle-native-proxy compatibility)
7197
processed = strings.ReplaceAll(processed, "{-}", "-")
7298
processed = strings.ReplaceAll(processed, "{--}", "--")

pkg/command/builder_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package command
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestGetRootPath(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
servicePrefix string
12+
expected string
13+
}{
14+
{
15+
name: "standard service prefix",
16+
servicePrefix: "/user/fakeuser/myapp/",
17+
expected: "/hub/user/fakeuser/myapp",
18+
},
19+
{
20+
name: "service prefix without trailing slash",
21+
servicePrefix: "/user/testuser/app",
22+
expected: "/hub/user/testuser/app",
23+
},
24+
{
25+
name: "service prefix without leading slash",
26+
servicePrefix: "user/demouser/app/",
27+
expected: "/hub/user/demouser/app",
28+
},
29+
{
30+
name: "empty service prefix",
31+
servicePrefix: "",
32+
expected: "",
33+
},
34+
{
35+
name: "simple service prefix",
36+
servicePrefix: "/user/alice/",
37+
expected: "/hub/user/alice",
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
// Set environment variable
44+
if tt.servicePrefix != "" {
45+
os.Setenv("JUPYTERHUB_SERVICE_PREFIX", tt.servicePrefix)
46+
} else {
47+
os.Unsetenv("JUPYTERHUB_SERVICE_PREFIX")
48+
}
49+
defer os.Unsetenv("JUPYTERHUB_SERVICE_PREFIX")
50+
51+
result := GetRootPath()
52+
if result != tt.expected {
53+
t.Errorf("GetRootPath() = %q, want %q", result, tt.expected)
54+
}
55+
})
56+
}
57+
}
58+
59+
func TestSubstitutePort(t *testing.T) {
60+
tests := []struct {
61+
name string
62+
command []string
63+
port int
64+
servicePrefix string
65+
expected []string
66+
}{
67+
{
68+
name: "substitute port only",
69+
command: []string{"python", "-m", "http.server", "{port}"},
70+
port: 8080,
71+
servicePrefix: "",
72+
expected: []string{"python", "-m", "http.server", "8080"},
73+
},
74+
{
75+
name: "substitute root_path only",
76+
command: []string{"myapp", "--root-path", "{root_path}"},
77+
port: 8080,
78+
servicePrefix: "/user/test/app/",
79+
expected: []string{"myapp", "--root-path", "/hub/user/test/app"},
80+
},
81+
{
82+
name: "substitute both port and root_path",
83+
command: []string{"myapp", "--port", "{port}", "--root-path", "{root_path}"},
84+
port: 9000,
85+
servicePrefix: "/user/bob/dashboard/",
86+
expected: []string{"myapp", "--port", "9000", "--root-path", "/hub/user/bob/dashboard"},
87+
},
88+
{
89+
name: "substitute dash placeholders",
90+
command: []string{"myapp", "{-}p", "{port}", "{--}root-path", "{root_path}"},
91+
port: 8888,
92+
servicePrefix: "/user/test/",
93+
expected: []string{"myapp", "-p", "8888", "--root-path", "/hub/user/test"},
94+
},
95+
{
96+
name: "strip single quotes",
97+
command: []string{"'myapp --port {port}'"},
98+
port: 3000,
99+
servicePrefix: "",
100+
expected: []string{"myapp --port 3000"},
101+
},
102+
{
103+
name: "strip double quotes",
104+
command: []string{`"myapp --root-path {root_path}"`},
105+
port: 3000,
106+
servicePrefix: "/user/demo/",
107+
expected: []string{"myapp --root-path /hub/user/demo"},
108+
},
109+
{
110+
name: "empty root_path when no service prefix",
111+
command: []string{"myapp", "--root-path", "{root_path}"},
112+
port: 5000,
113+
servicePrefix: "",
114+
expected: []string{"myapp", "--root-path", ""},
115+
},
116+
}
117+
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
// Set environment variable
121+
if tt.servicePrefix != "" {
122+
os.Setenv("JUPYTERHUB_SERVICE_PREFIX", tt.servicePrefix)
123+
} else {
124+
os.Unsetenv("JUPYTERHUB_SERVICE_PREFIX")
125+
}
126+
defer os.Unsetenv("JUPYTERHUB_SERVICE_PREFIX")
127+
128+
result := SubstitutePort(tt.command, tt.port)
129+
if len(result) != len(tt.expected) {
130+
t.Fatalf("SubstitutePort() returned %d args, want %d", len(result), len(tt.expected))
131+
}
132+
for i := range result {
133+
if result[i] != tt.expected[i] {
134+
t.Errorf("SubstitutePort()[%d] = %q, want %q", i, result[i], tt.expected[i])
135+
}
136+
}
137+
})
138+
}
139+
}

0 commit comments

Comments
 (0)