Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions examples/ssh-sidecar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SSH Sidecar Example

Mount your pod's filesystem locally for bidirectional editing with your local tools.

## Use Case

- Edit notebooks locally with your preferred IDE while they run in the cluster
- Sync files bidirectionally between local machine and pod
- Access pod filesystem without kubectl cp

## Quick Start

```bash
# 1. Create secret with your SSH public key
kubectl create secret generic ssh-pubkey \
--from-file=authorized_keys=~/.ssh/id_ed25519.pub

# 2. Deploy the notebook
kubectl apply -f notebook.yaml

# 3. Port-forward SSH
kubectl port-forward svc/ssh-notebook 2222:2222 &

# 4. Mount locally via sshfs
mkdir -p ./notebooks
sshfs marimo@localhost:/home/marimo/notebooks ./notebooks -p 2222

# 5. Edit files locally - changes sync to pod automatically
```

## Using kubectl-marimo Plugin

The plugin handles all of this automatically:

```bash
kubectl marimo deploy notebook.py --source sshfs:///home/marimo/notebooks
```

## Manual SSH Access

If sshfs isn't installed, you can still SSH directly:

```bash
ssh -p 2222 marimo@localhost -i ~/.ssh/id_ed25519
```
86 changes: 52 additions & 34 deletions examples/ssh-sidecar/notebook.yaml
Original file line number Diff line number Diff line change
@@ -1,55 +1,73 @@
# SSH Sidecar Example
# This deploys a marimo notebook with an SSH server sidecar
# allowing direct file access via SFTP/SCP
# This deploys a marimo notebook with an SSH sidecar for local filesystem access.
# Use this to mount the pod's filesystem locally via sshfs for bidirectional editing.
#
# Prerequisites:
# 1. Create the ssh-pubkey secret with your public key:
# kubectl create secret generic ssh-pubkey \
# --from-file=authorized_keys=~/.ssh/id_ed25519.pub
#
# 2. Deploy this notebook:
# kubectl apply -f notebook.yaml
#
# 3. Port-forward and mount locally:
# kubectl port-forward svc/ssh-notebook 2222:2222 &
# mkdir -p ./notebooks
# sshfs marimo@localhost:/home/marimo/notebooks ./notebooks -p 2222
#
# Or simply use the kubectl-marimo plugin which handles this automatically:
# kubectl marimo deploy notebook.py --source sshfs:///home/marimo/notebooks
apiVersion: marimo.io/v1alpha1
kind: MarimoNotebook
metadata:
name: ssh-notebook
namespace: default
spec:
source: "https://github.com/marimo-team/examples.git"

# Persistent storage (required for sidecars)
# Persistent storage for notebooks
storage:
size: "5Gi"
size: "2Gi"

# Disable auth for examples (empty auth block = --no-token)
# Disable auth for local development
auth: {}

# SSH sidecar for remote file access
# SSH sidecar for local filesystem access
sidecars:
- name: sshd
image: linuxserver/openssh-server:latest
- name: sshfs
image: lscr.io/linuxserver/openssh-server:latest
# Expose SSH port via the service
exposePort: 2222
env:
# Enable password authentication
# Disable password auth - key-based only
- name: PASSWORD_ACCESS
value: "true"
# Set a default user password (for demo only - use secrets in production!)
- name: USER_PASSWORD
value: "marimo"
# User to create
value: "false"
# Username for SSH connections
- name: USER_NAME
value: "marimo"
# Map to correct UID for file ownership
# User ID to match marimo container
- name: PUID
value: "1000"
- name: PGID
value: "1000"
---
# For production, use a Secret for the SSH password:
# apiVersion: v1
# kind: Secret
# metadata:
# name: ssh-credentials
# type: Opaque
# stringData:
# password: "your-secure-password"
#
# Then reference it in the sidecar env:
# env:
# - name: USER_PASSWORD
# valueFrom:
# secretKeyRef:
# name: ssh-credentials
# key: password
# Path to authorized_keys from secret
- name: PUBLIC_KEY_FILE
value: "/config/ssh-pubkey/authorized_keys"
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "256Mi"
# Mount the ssh-pubkey secret
volumeMounts:
- name: ssh-pubkey
mountPath: /config/ssh-pubkey
readOnly: true

# Pod overrides to add the secret volume
podOverrides:
volumes:
- name: ssh-pubkey
secret:
secretName: ssh-pubkey
defaultMode: 0600
169 changes: 33 additions & 136 deletions pkg/resources/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,28 @@ func BuildPod(notebook *marimov1alpha1.MarimoNotebook) *corev1.Pod {
// Check if any sidecar uses FUSE (privileged) - if so, marimo container needs
// HostToContainer propagation
hasFUSESidecar := false
hasSSHFSSidecar := false
for _, sidecar := range allSidecars {
if sidecar.SecurityContext != nil && sidecar.SecurityContext.Privileged != nil &&
*sidecar.SecurityContext.Privileged {
hasFUSESidecar = true
break
}
if strings.HasPrefix(sidecar.Name, "sshfs-") {
hasSSHFSSidecar = true
}
}

// Add ssh-pubkey secret volume if any sshfs sidecar exists
// The plugin creates this secret from the user's public key
if hasSSHFSSidecar {
volumes = append(volumes, corev1.Volume{
Name: "ssh-pubkey",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: "ssh-pubkey",
},
},
})
}

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

// SSHFS sidecars need the ssh-pubkey secret for key-based auth
// The linuxserver/openssh-server image reads PUBLIC_KEY_FILE from this path
if strings.HasPrefix(sidecar.Name, "sshfs-") {
sidecarMounts = append(sidecarMounts, corev1.VolumeMount{
Name: "ssh-pubkey",
MountPath: "/config/ssh-pubkey",
ReadOnly: true,
})
}

container := corev1.Container{
Name: sidecar.Name,
Image: sidecar.Image,
Expand Down Expand Up @@ -369,36 +396,6 @@ func applyPodOverrides(base, overrides corev1.PodSpec) corev1.PodSpec {
return result
}

// parseRemoteMountURI parses a remote mount URI with optional custom mount point.
// Format: scheme://user@host:/source or scheme://user@host:/source:/mount
// Returns: (userHost, sourcePath, mountPoint)
// If no custom mount point specified, mountPoint is empty.
func parseRemoteMountURI(uri, scheme string) (userHost, sourcePath, mountPoint string) {
trimmed := strings.TrimPrefix(uri, scheme+"://")

// Split at first : that follows a / to get user@host
colonIdx := strings.Index(trimmed, ":/")
if colonIdx == -1 {
return "", "", ""
}

userHost = trimmed[:colonIdx]
pathPart := trimmed[colonIdx+1:] // includes leading /

// Check for custom mount point (another : followed by /)
// /data:/mnt → source=/data, mount=/mnt
lastColonIdx := strings.LastIndex(pathPart, ":/")
if lastColonIdx > 0 {
sourcePath = pathPart[:lastColonIdx]
mountPoint = pathPart[lastColonIdx+1:]
} else {
sourcePath = pathPart
mountPoint = ""
}

return userHost, sourcePath, mountPoint
}

// parseCWMountURI parses a cw:// URI for CoreWeave S3 mounts.
// Format: cw://bucket[/path][:mount_point]
// Returns: (bucket, subpath, mountPoint)
Expand All @@ -424,126 +421,26 @@ func parseCWMountURI(uri string) (bucket, subpath, mountPoint string) {

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

for i, mount := range mounts {
if strings.HasPrefix(mount, "sshfs://") {
if sidecar := buildSSHFSSidecar(mount, i); sidecar != nil {
sidecars = append(sidecars, *sidecar)
}
} else if strings.HasPrefix(mount, "rsync://") {
if sidecar := buildRsyncSidecar(mount, i); sidecar != nil {
sidecars = append(sidecars, *sidecar)
}
} else if strings.HasPrefix(mount, "cw://") {
if strings.HasPrefix(mount, "cw://") {
if sidecar := buildCWSidecar(mount, i); sidecar != nil {
sidecars = append(sidecars, *sidecar)
}
}
// sshfs:// and rsync:// are handled by plugin - ignore here
}

return sidecars
}

// buildSSHFSSidecar creates a sidecar spec for SSHFS mount.
// URI format: sshfs://user@host:/remote/path or sshfs://user@host:/remote/path:/mount
// The sidecar mounts the remote path to the specified mount point or default location.
func buildSSHFSSidecar(uri string, index int) *marimov1alpha1.SidecarSpec {
userHost, remotePath, customMount := parseRemoteMountURI(uri, "sshfs")
if userHost == "" || remotePath == "" {
return nil
}

// Generate a unique name for the mount
mountName := fmt.Sprintf("sshfs-%d", index)

// Use custom mount point or default to /home/marimo/notebooks/mounts/<name>
localMountPoint := customMount
if localMountPoint == "" {
localMountPoint = fmt.Sprintf("%s/mounts/%s", NotebookDir, mountName)
}

return &marimov1alpha1.SidecarSpec{
Name: mountName,
Image: config.AlpineImage,
Command: []string{"sh", "-c"},
Args: []string{
fmt.Sprintf(
"apk add --no-cache sshfs openssh-client && mkdir -p %s && "+
"sshfs -o StrictHostKeyChecking=no,UserKnownHostsFile=/dev/null,"+
"reconnect,ServerAliveInterval=15,allow_other %s:%s %s && sleep infinity",
localMountPoint,
userHost,
remotePath,
localMountPoint,
),
},
Env: []corev1.EnvVar{
// SSH key should be mounted from a secret named "ssh-credentials"
// The user can configure this via podOverrides if needed
},
// FUSE requires privileged access to /dev/fuse
SecurityContext: &corev1.SecurityContext{
Privileged: ptrBool(true),
},
}
}

// buildRsyncSidecar creates a sidecar spec for rsync-based file sync.
// URI format: rsync://user@host:/remote/path or rsync://user@host:/remote/path:/mount
// No FUSE required - works unprivileged.
// Behavior: initial sync from remote, then watches local changes and syncs back.
func buildRsyncSidecar(uri string, index int) *marimov1alpha1.SidecarSpec {
userHost, remotePath, customMount := parseRemoteMountURI(uri, "rsync")
if userHost == "" || remotePath == "" {
return nil
}

mountName := fmt.Sprintf("rsync-%d", index)

// Use custom mount point or default to /home/marimo/notebooks/mounts/<name>
localMountPoint := customMount
if localMountPoint == "" {
localMountPoint = fmt.Sprintf("%s/mounts/%s", NotebookDir, mountName)
}

return &marimov1alpha1.SidecarSpec{
Name: mountName,
Image: config.AlpineImage,
Command: []string{"sh", "-c"},
Args: []string{
fmt.Sprintf(
"apk add --no-cache openssh-client rsync inotify-tools && "+
"mkdir -p %s && "+
"echo 'Initial sync from %s:%s' && "+
"rsync -avz -e 'ssh -o StrictHostKeyChecking=no "+
"-o UserKnownHostsFile=/dev/null' %s:%s/ %s/ || "+
"echo 'Initial sync failed (check SSH credentials)' && "+
"echo 'Watching for changes...' && "+
"while inotifywait -r -e modify,create,delete %s 2>/dev/null; do "+
"rsync -avz -e 'ssh -o StrictHostKeyChecking=no "+
"-o UserKnownHostsFile=/dev/null' %s/ %s:%s/; "+
"done",
localMountPoint,
userHost, remotePath,
userHost, remotePath, localMountPoint,
localMountPoint,
localMountPoint, userHost, remotePath,
),
},
Env: []corev1.EnvVar{
// SSH key should be mounted from a secret named "ssh-credentials"
},
}
}

// CWCredentialsSecret is the name of the K8s secret containing S3 credentials.
// The kubectl-marimo plugin auto-creates this from ~/.s3cfg.
const CWCredentialsSecret = "cw-credentials"
Expand Down
Loading