Skip to content

Commit f822ab4

Browse files
authored
fix: ssh sidecar and mount fix (#2)
* fix: ssh sidecar and mount fix * fix: sshfs workflow * core: go lint * fix: missing implementation
1 parent 21f8051 commit f822ab4

File tree

18 files changed

+1158
-657
lines changed

18 files changed

+1158
-657
lines changed

examples/ssh-sidecar/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SSH Sidecar Example
2+
3+
Mount your pod's filesystem locally for bidirectional editing with your local tools.
4+
5+
## Use Case
6+
7+
- Edit notebooks locally with your preferred IDE while they run in the cluster
8+
- Sync files bidirectionally between local machine and pod
9+
- Access pod filesystem without kubectl cp
10+
11+
## Quick Start
12+
13+
```bash
14+
# 1. Create secret with your SSH public key
15+
kubectl create secret generic ssh-pubkey \
16+
--from-file=authorized_keys=~/.ssh/id_ed25519.pub
17+
18+
# 2. Deploy the notebook
19+
kubectl apply -f notebook.yaml
20+
21+
# 3. Port-forward SSH
22+
kubectl port-forward svc/ssh-notebook 2222:2222 &
23+
24+
# 4. Mount locally via sshfs
25+
mkdir -p ./notebooks
26+
sshfs marimo@localhost:/home/marimo/notebooks ./notebooks -p 2222
27+
28+
# 5. Edit files locally - changes sync to pod automatically
29+
```
30+
31+
## Using kubectl-marimo Plugin
32+
33+
The plugin handles all of this automatically:
34+
35+
```bash
36+
kubectl marimo deploy notebook.py --source sshfs:///home/marimo/notebooks
37+
```
38+
39+
## Manual SSH Access
40+
41+
If sshfs isn't installed, you can still SSH directly:
42+
43+
```bash
44+
ssh -p 2222 marimo@localhost -i ~/.ssh/id_ed25519
45+
```

examples/ssh-sidecar/notebook.yaml

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,73 @@
11
# SSH Sidecar Example
2-
# This deploys a marimo notebook with an SSH server sidecar
3-
# allowing direct file access via SFTP/SCP
2+
# This deploys a marimo notebook with an SSH sidecar for local filesystem access.
3+
# Use this to mount the pod's filesystem locally via sshfs for bidirectional editing.
4+
#
5+
# Prerequisites:
6+
# 1. Create the ssh-pubkey secret with your public key:
7+
# kubectl create secret generic ssh-pubkey \
8+
# --from-file=authorized_keys=~/.ssh/id_ed25519.pub
9+
#
10+
# 2. Deploy this notebook:
11+
# kubectl apply -f notebook.yaml
12+
#
13+
# 3. Port-forward and mount locally:
14+
# kubectl port-forward svc/ssh-notebook 2222:2222 &
15+
# mkdir -p ./notebooks
16+
# sshfs marimo@localhost:/home/marimo/notebooks ./notebooks -p 2222
17+
#
18+
# Or simply use the kubectl-marimo plugin which handles this automatically:
19+
# kubectl marimo deploy notebook.py --source sshfs:///home/marimo/notebooks
420
apiVersion: marimo.io/v1alpha1
521
kind: MarimoNotebook
622
metadata:
723
name: ssh-notebook
824
namespace: default
925
spec:
10-
source: "https://github.com/marimo-team/examples.git"
11-
12-
# Persistent storage (required for sidecars)
26+
# Persistent storage for notebooks
1327
storage:
14-
size: "5Gi"
28+
size: "2Gi"
1529

16-
# Disable auth for examples (empty auth block = --no-token)
30+
# Disable auth for local development
1731
auth: {}
1832

19-
# SSH sidecar for remote file access
33+
# SSH sidecar for local filesystem access
2034
sidecars:
21-
- name: sshd
22-
image: linuxserver/openssh-server:latest
35+
- name: sshfs
36+
image: lscr.io/linuxserver/openssh-server:latest
37+
# Expose SSH port via the service
2338
exposePort: 2222
2439
env:
25-
# Enable password authentication
40+
# Disable password auth - key-based only
2641
- name: PASSWORD_ACCESS
27-
value: "true"
28-
# Set a default user password (for demo only - use secrets in production!)
29-
- name: USER_PASSWORD
30-
value: "marimo"
31-
# User to create
42+
value: "false"
43+
# Username for SSH connections
3244
- name: USER_NAME
3345
value: "marimo"
34-
# Map to correct UID for file ownership
46+
# User ID to match marimo container
3547
- name: PUID
3648
value: "1000"
3749
- name: PGID
3850
value: "1000"
39-
---
40-
# For production, use a Secret for the SSH password:
41-
# apiVersion: v1
42-
# kind: Secret
43-
# metadata:
44-
# name: ssh-credentials
45-
# type: Opaque
46-
# stringData:
47-
# password: "your-secure-password"
48-
#
49-
# Then reference it in the sidecar env:
50-
# env:
51-
# - name: USER_PASSWORD
52-
# valueFrom:
53-
# secretKeyRef:
54-
# name: ssh-credentials
55-
# key: password
51+
# Path to authorized_keys from secret
52+
- name: PUBLIC_KEY_FILE
53+
value: "/config/ssh-pubkey/authorized_keys"
54+
resources:
55+
requests:
56+
cpu: "50m"
57+
memory: "64Mi"
58+
limits:
59+
cpu: "200m"
60+
memory: "256Mi"
61+
# Mount the ssh-pubkey secret
62+
volumeMounts:
63+
- name: ssh-pubkey
64+
mountPath: /config/ssh-pubkey
65+
readOnly: true
66+
67+
# Pod overrides to add the secret volume
68+
podOverrides:
69+
volumes:
70+
- name: ssh-pubkey
71+
secret:
72+
secretName: ssh-pubkey
73+
defaultMode: 0600

pkg/resources/pod.go

Lines changed: 33 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,28 @@ func BuildPod(notebook *marimov1alpha1.MarimoNotebook) *corev1.Pod {
207207
// Check if any sidecar uses FUSE (privileged) - if so, marimo container needs
208208
// HostToContainer propagation
209209
hasFUSESidecar := false
210+
hasSSHFSSidecar := false
210211
for _, sidecar := range allSidecars {
211212
if sidecar.SecurityContext != nil && sidecar.SecurityContext.Privileged != nil &&
212213
*sidecar.SecurityContext.Privileged {
213214
hasFUSESidecar = true
214-
break
215215
}
216+
if strings.HasPrefix(sidecar.Name, "sshfs-") {
217+
hasSSHFSSidecar = true
218+
}
219+
}
220+
221+
// Add ssh-pubkey secret volume if any sshfs sidecar exists
222+
// The plugin creates this secret from the user's public key
223+
if hasSSHFSSidecar {
224+
volumes = append(volumes, corev1.Volume{
225+
Name: "ssh-pubkey",
226+
VolumeSource: corev1.VolumeSource{
227+
Secret: &corev1.SecretVolumeSource{
228+
SecretName: "ssh-pubkey",
229+
},
230+
},
231+
})
216232
}
217233

218234
// If there are FUSE sidecars, update marimo's volume mount with HostToContainer propagation
@@ -280,6 +296,7 @@ func BuildPod(notebook *marimov1alpha1.MarimoNotebook) *corev1.Pod {
280296
// buildSidecarContainer creates a container spec from a SidecarSpec.
281297
// Sidecars share the PVC volume with the main marimo container.
282298
// FUSE-based sidecars (with privileged security context) get Bidirectional mount propagation.
299+
// SSHFS sidecars (name starts with "sshfs-") get the ssh-pubkey secret mounted.
283300
func buildSidecarContainer(sidecar marimov1alpha1.SidecarSpec, volumeMounts []corev1.VolumeMount) corev1.Container {
284301
// Copy volume mounts so we can modify them for this container
285302
sidecarMounts := make([]corev1.VolumeMount, len(volumeMounts))
@@ -296,6 +313,16 @@ func buildSidecarContainer(sidecar marimov1alpha1.SidecarSpec, volumeMounts []co
296313
}
297314
}
298315

316+
// SSHFS sidecars need the ssh-pubkey secret for key-based auth
317+
// The linuxserver/openssh-server image reads PUBLIC_KEY_FILE from this path
318+
if strings.HasPrefix(sidecar.Name, "sshfs-") {
319+
sidecarMounts = append(sidecarMounts, corev1.VolumeMount{
320+
Name: "ssh-pubkey",
321+
MountPath: "/config/ssh-pubkey",
322+
ReadOnly: true,
323+
})
324+
}
325+
299326
container := corev1.Container{
300327
Name: sidecar.Name,
301328
Image: sidecar.Image,
@@ -369,36 +396,6 @@ func applyPodOverrides(base, overrides corev1.PodSpec) corev1.PodSpec {
369396
return result
370397
}
371398

372-
// parseRemoteMountURI parses a remote mount URI with optional custom mount point.
373-
// Format: scheme://user@host:/source or scheme://user@host:/source:/mount
374-
// Returns: (userHost, sourcePath, mountPoint)
375-
// If no custom mount point specified, mountPoint is empty.
376-
func parseRemoteMountURI(uri, scheme string) (userHost, sourcePath, mountPoint string) {
377-
trimmed := strings.TrimPrefix(uri, scheme+"://")
378-
379-
// Split at first : that follows a / to get user@host
380-
colonIdx := strings.Index(trimmed, ":/")
381-
if colonIdx == -1 {
382-
return "", "", ""
383-
}
384-
385-
userHost = trimmed[:colonIdx]
386-
pathPart := trimmed[colonIdx+1:] // includes leading /
387-
388-
// Check for custom mount point (another : followed by /)
389-
// /data:/mnt → source=/data, mount=/mnt
390-
lastColonIdx := strings.LastIndex(pathPart, ":/")
391-
if lastColonIdx > 0 {
392-
sourcePath = pathPart[:lastColonIdx]
393-
mountPoint = pathPart[lastColonIdx+1:]
394-
} else {
395-
sourcePath = pathPart
396-
mountPoint = ""
397-
}
398-
399-
return userHost, sourcePath, mountPoint
400-
}
401-
402399
// parseCWMountURI parses a cw:// URI for CoreWeave S3 mounts.
403400
// Format: cw://bucket[/path][:mount_point]
404401
// Returns: (bucket, subpath, mountPoint)
@@ -424,126 +421,26 @@ func parseCWMountURI(uri string) (bucket, subpath, mountPoint string) {
424421

425422
// expandMounts converts mount URIs to sidecar specs.
426423
// Supported schemes:
427-
// - sshfs://user@host:/remote/path → SSHFS sidecar (requires FUSE)
428-
// - sshfs://user@host:/remote/path:/mount → SSHFS with custom mount point
429-
// - rsync://user@host:/remote/path → rsync sidecar (no FUSE, periodic sync)
430-
// - rsync://user@host:/remote/path:/mount → rsync with custom mount point
431424
// - cw://bucket/path → CoreWeave S3 sidecar using s3fs
432425
// - cw://bucket/path:/mount → CoreWeave S3 with custom mount point
426+
//
427+
// Note: sshfs:// and rsync:// mounts are handled by the kubectl-marimo plugin,
428+
// not the operator. The plugin adds explicit sidecar specs to the CRD.
433429
func expandMounts(mounts []string) []marimov1alpha1.SidecarSpec {
434430
var sidecars []marimov1alpha1.SidecarSpec
435431

436432
for i, mount := range mounts {
437-
if strings.HasPrefix(mount, "sshfs://") {
438-
if sidecar := buildSSHFSSidecar(mount, i); sidecar != nil {
439-
sidecars = append(sidecars, *sidecar)
440-
}
441-
} else if strings.HasPrefix(mount, "rsync://") {
442-
if sidecar := buildRsyncSidecar(mount, i); sidecar != nil {
443-
sidecars = append(sidecars, *sidecar)
444-
}
445-
} else if strings.HasPrefix(mount, "cw://") {
433+
if strings.HasPrefix(mount, "cw://") {
446434
if sidecar := buildCWSidecar(mount, i); sidecar != nil {
447435
sidecars = append(sidecars, *sidecar)
448436
}
449437
}
438+
// sshfs:// and rsync:// are handled by plugin - ignore here
450439
}
451440

452441
return sidecars
453442
}
454443

455-
// buildSSHFSSidecar creates a sidecar spec for SSHFS mount.
456-
// URI format: sshfs://user@host:/remote/path or sshfs://user@host:/remote/path:/mount
457-
// The sidecar mounts the remote path to the specified mount point or default location.
458-
func buildSSHFSSidecar(uri string, index int) *marimov1alpha1.SidecarSpec {
459-
userHost, remotePath, customMount := parseRemoteMountURI(uri, "sshfs")
460-
if userHost == "" || remotePath == "" {
461-
return nil
462-
}
463-
464-
// Generate a unique name for the mount
465-
mountName := fmt.Sprintf("sshfs-%d", index)
466-
467-
// Use custom mount point or default to /home/marimo/notebooks/mounts/<name>
468-
localMountPoint := customMount
469-
if localMountPoint == "" {
470-
localMountPoint = fmt.Sprintf("%s/mounts/%s", NotebookDir, mountName)
471-
}
472-
473-
return &marimov1alpha1.SidecarSpec{
474-
Name: mountName,
475-
Image: config.AlpineImage,
476-
Command: []string{"sh", "-c"},
477-
Args: []string{
478-
fmt.Sprintf(
479-
"apk add --no-cache sshfs openssh-client && mkdir -p %s && "+
480-
"sshfs -o StrictHostKeyChecking=no,UserKnownHostsFile=/dev/null,"+
481-
"reconnect,ServerAliveInterval=15,allow_other %s:%s %s && sleep infinity",
482-
localMountPoint,
483-
userHost,
484-
remotePath,
485-
localMountPoint,
486-
),
487-
},
488-
Env: []corev1.EnvVar{
489-
// SSH key should be mounted from a secret named "ssh-credentials"
490-
// The user can configure this via podOverrides if needed
491-
},
492-
// FUSE requires privileged access to /dev/fuse
493-
SecurityContext: &corev1.SecurityContext{
494-
Privileged: ptrBool(true),
495-
},
496-
}
497-
}
498-
499-
// buildRsyncSidecar creates a sidecar spec for rsync-based file sync.
500-
// URI format: rsync://user@host:/remote/path or rsync://user@host:/remote/path:/mount
501-
// No FUSE required - works unprivileged.
502-
// Behavior: initial sync from remote, then watches local changes and syncs back.
503-
func buildRsyncSidecar(uri string, index int) *marimov1alpha1.SidecarSpec {
504-
userHost, remotePath, customMount := parseRemoteMountURI(uri, "rsync")
505-
if userHost == "" || remotePath == "" {
506-
return nil
507-
}
508-
509-
mountName := fmt.Sprintf("rsync-%d", index)
510-
511-
// Use custom mount point or default to /home/marimo/notebooks/mounts/<name>
512-
localMountPoint := customMount
513-
if localMountPoint == "" {
514-
localMountPoint = fmt.Sprintf("%s/mounts/%s", NotebookDir, mountName)
515-
}
516-
517-
return &marimov1alpha1.SidecarSpec{
518-
Name: mountName,
519-
Image: config.AlpineImage,
520-
Command: []string{"sh", "-c"},
521-
Args: []string{
522-
fmt.Sprintf(
523-
"apk add --no-cache openssh-client rsync inotify-tools && "+
524-
"mkdir -p %s && "+
525-
"echo 'Initial sync from %s:%s' && "+
526-
"rsync -avz -e 'ssh -o StrictHostKeyChecking=no "+
527-
"-o UserKnownHostsFile=/dev/null' %s:%s/ %s/ || "+
528-
"echo 'Initial sync failed (check SSH credentials)' && "+
529-
"echo 'Watching for changes...' && "+
530-
"while inotifywait -r -e modify,create,delete %s 2>/dev/null; do "+
531-
"rsync -avz -e 'ssh -o StrictHostKeyChecking=no "+
532-
"-o UserKnownHostsFile=/dev/null' %s/ %s:%s/; "+
533-
"done",
534-
localMountPoint,
535-
userHost, remotePath,
536-
userHost, remotePath, localMountPoint,
537-
localMountPoint,
538-
localMountPoint, userHost, remotePath,
539-
),
540-
},
541-
Env: []corev1.EnvVar{
542-
// SSH key should be mounted from a secret named "ssh-credentials"
543-
},
544-
}
545-
}
546-
547444
// CWCredentialsSecret is the name of the K8s secret containing S3 credentials.
548445
// The kubectl-marimo plugin auto-creates this from ~/.s3cfg.
549446
const CWCredentialsSecret = "cw-credentials"

0 commit comments

Comments
 (0)