diff --git a/examples/ssh-sidecar/README.md b/examples/ssh-sidecar/README.md new file mode 100644 index 0000000..9cff992 --- /dev/null +++ b/examples/ssh-sidecar/README.md @@ -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 +``` diff --git a/examples/ssh-sidecar/notebook.yaml b/examples/ssh-sidecar/notebook.yaml index 7d23128..51e4019 100644 --- a/examples/ssh-sidecar/notebook.yaml +++ b/examples/ssh-sidecar/notebook.yaml @@ -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 diff --git a/pkg/resources/pod.go b/pkg/resources/pod.go index 6e30378..24e9f41 100644 --- a/pkg/resources/pod.go +++ b/pkg/resources/pod.go @@ -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 @@ -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)) @@ -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, @@ -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) @@ -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/ - 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/ - 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" diff --git a/pkg/resources/pod_test.go b/pkg/resources/pod_test.go index 61fec62..43a851d 100644 --- a/pkg/resources/pod_test.go +++ b/pkg/resources/pod_test.go @@ -16,6 +16,8 @@ const ( testSetupVenv = "setup-venv" testSSHDContainer = "sshd" testSSHFSName = "sshfs-0" + testCWSidecarName = "cw-0" + testSSHPubkeyName = "ssh-pubkey" ) func TestBuildPod_BasicConfig(t *testing.T) { @@ -1067,58 +1069,17 @@ func TestBuildPod_EnvVarsEmpty(t *testing.T) { } } -func TestExpandMounts_SSHFS(t *testing.T) { +func TestExpandMounts_SSHFSIgnored(t *testing.T) { + // sshfs:// mounts are handled by the plugin, not the operator mounts := []string{ - "sshfs://user@host.example.com:/data/notebooks", + "sshfs:///home/marimo/notebooks", } sidecars := expandMounts(mounts) - if len(sidecars) != 1 { - t.Fatalf("expected 1 sidecar, got %d", len(sidecars)) - } - - sidecar := sidecars[0] - if sidecar.Name != testSSHFSName { - t.Errorf("expected name '%s', got '%s'", testSSHFSName, sidecar.Name) - } - if sidecar.Image != "alpine:latest" { - t.Errorf("expected alpine:latest image, got '%s'", sidecar.Image) - } - if len(sidecar.Command) != 2 || sidecar.Command[0] != "sh" { - t.Errorf("expected command 'sh -c', got %v", sidecar.Command) - } - if len(sidecar.Args) != 1 { - t.Errorf("expected 1 arg, got %d", len(sidecar.Args)) - } - // Check the sshfs command contains the host and path - arg := sidecar.Args[0] - if !strings.Contains(arg, "user@host.example.com") { - t.Errorf("expected arg to contain 'user@host.example.com', got '%s'", arg) - } - if !strings.Contains(arg, "/data/notebooks") { - t.Errorf("expected arg to contain '/data/notebooks', got '%s'", arg) - } -} - -func TestExpandMounts_MultipleMounts(t *testing.T) { - mounts := []string{ - "sshfs://user1@host1:/path1", - "sshfs://user2@host2:/path2", - } - - sidecars := expandMounts(mounts) - - if len(sidecars) != 2 { - t.Fatalf("expected 2 sidecars, got %d", len(sidecars)) - } - - if sidecars[0].Name != testSSHFSName { - t.Errorf("expected first sidecar name '%s', got '%s'", - testSSHFSName, sidecars[0].Name) - } - if sidecars[1].Name != "sshfs-1" { - t.Errorf("expected second sidecar name 'sshfs-1', got '%s'", sidecars[1].Name) + // sshfs:// should be ignored (plugin handles it) + if len(sidecars) != 0 { + t.Errorf("expected 0 sidecars for sshfs:// (handled by plugin), got %d", len(sidecars)) } } @@ -1136,65 +1097,41 @@ func TestExpandMounts_UnsupportedScheme(t *testing.T) { } } -func TestExpandMounts_Rsync(t *testing.T) { +func TestExpandMounts_RsyncIgnored(t *testing.T) { + // rsync:// mounts are handled by the plugin, not the operator mounts := []string{ - "rsync://user@host.example.com:/data/notebooks", + "rsync://./local/data", } sidecars := expandMounts(mounts) - if len(sidecars) != 1 { - t.Fatalf("expected 1 sidecar, got %d", len(sidecars)) - } - - sidecar := sidecars[0] - if sidecar.Name != "rsync-0" { - t.Errorf("expected name 'rsync-0', got '%s'", sidecar.Name) - } - if sidecar.Image != "alpine:latest" { - t.Errorf("expected alpine:latest image, got '%s'", sidecar.Image) - } - if len(sidecar.Command) != 2 || sidecar.Command[0] != "sh" { - t.Errorf("expected command 'sh -c', got %v", sidecar.Command) - } - if len(sidecar.Args) != 1 { - t.Errorf("expected 1 arg, got %d", len(sidecar.Args)) - } - // Check the rsync command contains the host and path - arg := sidecar.Args[0] - if !strings.Contains(arg, "rsync") { - t.Errorf("expected arg to contain 'rsync', got '%s'", arg) - } - if !strings.Contains(arg, "user@host.example.com") { - t.Errorf("expected arg to contain 'user@host.example.com', got '%s'", arg) - } - if !strings.Contains(arg, "/data/notebooks") { - t.Errorf("expected arg to contain '/data/notebooks', got '%s'", arg) + // rsync:// should be ignored (plugin handles it) + if len(sidecars) != 0 { + t.Errorf("expected 0 sidecars for rsync:// (handled by plugin), got %d", len(sidecars)) } } func TestExpandMounts_MixedSchemes(t *testing.T) { + // Only cw:// should produce sidecars, sshfs:// and rsync:// are handled by plugin mounts := []string{ - "sshfs://user1@host1:/path1", - "rsync://user2@host2:/path2", + "sshfs:///path1", + "rsync://./path2", + "cw://bucket/path3", } sidecars := expandMounts(mounts) - if len(sidecars) != 2 { - t.Fatalf("expected 2 sidecars, got %d", len(sidecars)) + // Only cw:// should produce sidecar + if len(sidecars) != 1 { + t.Fatalf("expected 1 sidecar (cw:// only), got %d", len(sidecars)) } - if sidecars[0].Name != testSSHFSName { - t.Errorf("expected first sidecar name '%s', got '%s'", - testSSHFSName, sidecars[0].Name) - } - if sidecars[1].Name != "rsync-1" { - t.Errorf("expected second sidecar name 'rsync-1', got '%s'", sidecars[1].Name) + if sidecars[0].Name != "cw-2" { + t.Errorf("expected sidecar name 'cw-2', got '%s'", sidecars[0].Name) } } -func TestBuildPod_WithMounts(t *testing.T) { +func TestBuildPod_WithCWMounts(t *testing.T) { notebook := &marimov1alpha1.MarimoNotebook{ ObjectMeta: metav1.ObjectMeta{ Name: "test-notebook", @@ -1205,7 +1142,7 @@ func TestBuildPod_WithMounts(t *testing.T) { Port: 2718, Source: "https://github.com/marimo-team/marimo.git", Mounts: []string{ - "sshfs://user@host:/remote/data", + "cw://mybucket/data", }, Storage: &marimov1alpha1.StorageSpec{ Size: "1Gi", @@ -1215,7 +1152,7 @@ func TestBuildPod_WithMounts(t *testing.T) { pod := BuildPod(notebook) - // Should have marimo + 1 sshfs sidecar + // Should have marimo + 1 cw sidecar if len(pod.Spec.Containers) != 2 { t.Fatalf("expected 2 containers, got %d", len(pod.Spec.Containers)) } @@ -1226,115 +1163,10 @@ func TestBuildPod_WithMounts(t *testing.T) { testMarimoContainer, pod.Spec.Containers[0].Name) } - // Second container should be sshfs sidecar - sshfsSidecar := pod.Spec.Containers[1] - if sshfsSidecar.Name != testSSHFSName { - t.Errorf("expected sidecar name '%s', got '%s'", testSSHFSName, sshfsSidecar.Name) - } -} - -func TestParseRemoteMountURI_Basic(t *testing.T) { - tests := []struct { - name string - uri string - scheme string - wantUserHost string - wantSourcePath string - wantMountPoint string - }{ - { - name: "rsync basic", - uri: "rsync://user@host:/remote/path", - scheme: "rsync", - wantUserHost: "user@host", - wantSourcePath: "/remote/path", - wantMountPoint: "", - }, - { - name: "rsync with custom mount", - uri: "rsync://user@host:/data:/mnt/custom", - scheme: "rsync", - wantUserHost: "user@host", - wantSourcePath: "/data", - wantMountPoint: "/mnt/custom", - }, - { - name: "sshfs basic", - uri: "sshfs://admin@server:/files", - scheme: "sshfs", - wantUserHost: "admin@server", - wantSourcePath: "/files", - wantMountPoint: "", - }, - { - name: "sshfs with custom mount", - uri: "sshfs://admin@server:/files:/home/marimo/data", - scheme: "sshfs", - wantUserHost: "admin@server", - wantSourcePath: "/files", - wantMountPoint: "/home/marimo/data", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - userHost, sourcePath, mountPoint := parseRemoteMountURI(tt.uri, tt.scheme) - if userHost != tt.wantUserHost { - t.Errorf("parseRemoteMountURI() userHost = %q, want %q", userHost, tt.wantUserHost) - } - if sourcePath != tt.wantSourcePath { - t.Errorf("parseRemoteMountURI() sourcePath = %q, want %q", sourcePath, tt.wantSourcePath) - } - if mountPoint != tt.wantMountPoint { - t.Errorf("parseRemoteMountURI() mountPoint = %q, want %q", mountPoint, tt.wantMountPoint) - } - }) - } -} - -func TestExpandMounts_CustomMountPoint(t *testing.T) { - // Test rsync with custom mount point - mounts := []string{ - "rsync://user@host:/data:/mnt/custom", - } - - sidecars := expandMounts(mounts) - - if len(sidecars) != 1 { - t.Fatalf("expected 1 sidecar, got %d", len(sidecars)) - } - - // Check that the sidecar command contains the custom mount point - args := sidecars[0].Args - if len(args) != 1 { - t.Fatalf("expected 1 arg, got %d", len(args)) - } - - if !strings.Contains(args[0], "/mnt/custom") { - t.Errorf("expected args to contain '/mnt/custom', got %s", args[0]) - } -} - -func TestExpandMounts_SSHFSCustomMountPoint(t *testing.T) { - // Test sshfs with custom mount point - mounts := []string{ - "sshfs://user@host:/data:/opt/data", - } - - sidecars := expandMounts(mounts) - - if len(sidecars) != 1 { - t.Fatalf("expected 1 sidecar, got %d", len(sidecars)) - } - - // Check that the sidecar command contains the custom mount point - args := sidecars[0].Args - if len(args) != 1 { - t.Fatalf("expected 1 arg, got %d", len(args)) - } - - if !strings.Contains(args[0], "/opt/data") { - t.Errorf("expected args to contain '/opt/data', got %s", args[0]) + // Second container should be cw sidecar + cwSidecar := pod.Spec.Containers[1] + if cwSidecar.Name != testCWSidecarName { + t.Errorf("expected sidecar name '%s', got '%s'", testCWSidecarName, cwSidecar.Name) } } @@ -1378,8 +1210,8 @@ func TestExpandMounts_CW(t *testing.T) { } sidecar := sidecars[0] - if sidecar.Name != "cw-0" { - t.Errorf("expected name 'cw-0', got %q", sidecar.Name) + if sidecar.Name != testCWSidecarName { + t.Errorf("expected name '%s', got %q", testCWSidecarName, sidecar.Name) } if !strings.Contains(sidecar.Image, "s3fs") { @@ -1505,7 +1337,7 @@ func TestBuildPod_MountPropagation_WithFUSESidecar(t *testing.T) { if pod.Spec.Containers[i].Name == testMarimoContainer { marimoContainer = &pod.Spec.Containers[i] } - if pod.Spec.Containers[i].Name == "cw-0" { + if pod.Spec.Containers[i].Name == testCWSidecarName { cwContainer = &pod.Spec.Containers[i] } } @@ -1514,7 +1346,7 @@ func TestBuildPod_MountPropagation_WithFUSESidecar(t *testing.T) { t.Fatal("marimo container not found") } if cwContainer == nil { - t.Fatal("cw-0 container not found") + t.Fatalf("%s container not found", testCWSidecarName) } // Check marimo has HostToContainer propagation on PVC mount @@ -1583,3 +1415,95 @@ func TestBuildPod_MountPropagation_WithoutFUSESidecar(t *testing.T) { } } } + +func TestBuildPod_SSHFSSidecar_SecretMount(t *testing.T) { + // When a sidecar named "sshfs-*" is present, ssh-pubkey secret should be mounted + port := int32(2222) + notebook := &marimov1alpha1.MarimoNotebook{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-notebook", + Namespace: "default", + }, + Spec: marimov1alpha1.MarimoNotebookSpec{ + Image: "ghcr.io/marimo-team/marimo:latest", + Port: 2718, + Content: ptrString("# test notebook"), + Storage: &marimov1alpha1.StorageSpec{Size: "1Gi"}, + Sidecars: []marimov1alpha1.SidecarSpec{ + { + Name: "sshfs-0", + Image: "linuxserver/openssh-server:latest", + ExposePort: &port, + }, + }, + }, + } + + pod := BuildPod(notebook) + + // Check ssh-pubkey volume exists + var foundSSHPubkeyVolume bool + for _, vol := range pod.Spec.Volumes { + if vol.Name == testSSHPubkeyName { + if vol.Secret == nil || vol.Secret.SecretName != testSSHPubkeyName { + t.Errorf("%s volume should reference %s secret", testSSHPubkeyName, testSSHPubkeyName) + } + foundSSHPubkeyVolume = true + break + } + } + if !foundSSHPubkeyVolume { + t.Errorf("expected %s volume to be present for sshfs sidecar", testSSHPubkeyName) + } + + // Find sshfs sidecar and check it has the secret mounted + var sshfsSidecar *corev1.Container + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == "sshfs-0" { + sshfsSidecar = &pod.Spec.Containers[i] + break + } + } + + if sshfsSidecar == nil { + t.Fatal("sshfs-0 container not found") + } + + // Check ssh-pubkey is mounted at /config/ssh-pubkey + var foundSSHPubkeyMount bool + for _, vm := range sshfsSidecar.VolumeMounts { + if vm.Name == testSSHPubkeyName && vm.MountPath == "/config/"+testSSHPubkeyName && vm.ReadOnly { + foundSSHPubkeyMount = true + break + } + } + if !foundSSHPubkeyMount { + t.Error("sshfs sidecar should have ssh-pubkey mounted at /config/ssh-pubkey") + } +} + +func TestBuildPod_NoSSHFSSidecar_NoSecretMount(t *testing.T) { + // When no sshfs sidecar, ssh-pubkey secret should NOT be added + notebook := &marimov1alpha1.MarimoNotebook{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-notebook", + Namespace: "default", + }, + Spec: marimov1alpha1.MarimoNotebookSpec{ + Image: "ghcr.io/marimo-team/marimo:latest", + Port: 2718, + Content: ptrString("# test notebook"), + Storage: &marimov1alpha1.StorageSpec{Size: "1Gi"}, + // No sidecars + }, + } + + pod := BuildPod(notebook) + + // Check ssh-pubkey volume does NOT exist + for _, vol := range pod.Spec.Volumes { + if vol.Name == testSSHPubkeyName { + t.Errorf("%s volume should NOT be present when no sshfs sidecar", testSSHPubkeyName) + } + } +} diff --git a/plugin/README.md b/plugin/README.md index 37f999c..b56a4e5 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -106,7 +106,7 @@ kubectl marimo delete [OPTIONS] FILE Options: - `-n, --namespace` - Kubernetes namespace -- `--keep-pvc` - Preserve persistent storage +- `--delete-pvc` - Also delete PersistentVolumeClaim (PVC is preserved by default) - `--no-sync` - Delete without syncing changes back ### status diff --git a/plugin/examples/getting-started.py b/plugin/examples/getting-started.py new file mode 100644 index 0000000..6e8e28f --- /dev/null +++ b/plugin/examples/getting-started.py @@ -0,0 +1,83 @@ +# /// script +# dependencies = ["marimo"] +# +# [tool.marimo.k8s] +# storage = "1Gi" +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + mo.md(""" + # Welcome to marimo on Kubernetes! + + This notebook introduces marimo's **reactive execution model** — if you're + coming from Jupyter, this is the key difference to understand. + """) + return (mo,) + + +@app.cell +def _(mo): + slider = mo.ui.slider(1, 10, value=5, label="Pick a number") + slider + return (slider,) + + +@app.cell +def _(mo, slider): + mo.md(f""" + ## Reactive execution + + You picked **{slider.value}**. Try moving the slider above! + + Unlike Jupyter, marimo automatically re-runs cells when their dependencies + change. When you moved that slider, this cell re-ran instantly — no need + to manually execute anything. + + **Key differences from Jupyter:** + + 1. **No hidden state** — The notebook state always matches what you see + 2. **Cells run in dependency order** — Not top-to-bottom, but based on + which variables each cell uses + 3. **No cell numbers** — Order on the page doesn't determine execution order + """) + return + + +@app.cell +def _(mo, slider): + result = slider.value**2 + mo.md(f""" + ## Automatic updates + + The square of {slider.value} is **{result}**. + + This cell depends on `slider.value`, so it updates automatically when you + interact with the slider. marimo tracks these dependencies for you. + """) + return (result,) + + +@app.cell +def _(mo): + mo.md(""" + ## Next steps + + - **Edit this notebook** — Your changes persist to storage + - **Try more UI elements** — `mo.ui.dropdown()`, `mo.ui.text()`, + `mo.ui.checkbox()`, and [more](https://docs.marimo.io/api/inputs/) + - **Run as an app** — Use `kubectl marimo run` to serve as a read-only + dashboard + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/plugin/examples/gpu-getting-started.py b/plugin/examples/gpu-getting-started.py new file mode 100644 index 0000000..ad040a4 --- /dev/null +++ b/plugin/examples/gpu-getting-started.py @@ -0,0 +1,108 @@ +# /// script +# dependencies = ["marimo", "torch"] +# +# [tool.marimo.k8s] +# storage = "1Gi" +# +# [tool.marimo.k8s.resources] +# limits."nvidia.com/gpu" = 1 +# /// + +import marimo + +app = marimo.App() + + +@app.cell +def _(): + import marimo as mo + + mo.md(""" + # GPU Computing with marimo + + This notebook demonstrates GPU access and **caching** for expensive + computations. + """) + return (mo,) + + +@app.cell +def _(mo): + import torch + + gpu_available = torch.cuda.is_available() + device_name = torch.cuda.get_device_name(0) if gpu_available else "N/A" + device = "cuda" if gpu_available else "cpu" + + mo.md(f""" + ## GPU Status + + | Property | Value | + |----------|-------| + | CUDA Available | {gpu_available} | + | Device | {device_name} | + | PyTorch Version | {torch.__version__} | + """) + return device, gpu_available, torch + + +@app.cell +def _(mo): + size_slider = mo.ui.slider(100, 2000, value=500, step=100, label="Matrix size") + size_slider + return (size_slider,) + + +@app.cell +def _(device, mo, size_slider, torch): + @mo.persistent_cache + def matrix_multiply(n: int, device: str): + """Cached matrix multiplication — results saved to disk.""" + a = torch.randn(n, n, device=device) + b = torch.randn(n, n, device=device) + result = torch.mm(a, b) + return result.shape, str(result.device) + + shape, result_device = matrix_multiply(size_slider.value, device) + + mo.md(f""" + ## Persistent Cache + + Matrix multiplication: **{size_slider.value}×{size_slider.value}** + + - Result shape: `{shape}` + - Computed on: `{result_device}` + + The `@mo.persistent_cache` decorator saves results to disk. This means: + + 1. **Results survive notebook restarts** — no need to re-run expensive + computations when you reopen the notebook + 2. **Evaluate without GPU** — compute results on GPU once, then analyze + on cheaper CPU instances by reading from cache + 3. **Share results** — cached data persists in storage, accessible + across sessions + """) + return (matrix_multiply,) + + +@app.cell +def _(mo): + mo.md(""" + ## Caching strategies + + | Decorator | Persists | Use case | + |-----------|----------|----------| + | `@mo.cache` | In memory only | Fast, repeated calls in same session | + | `@mo.persistent_cache` | To disk | Expensive GPU ops, survive restarts | + + **Tip**: Run expensive training/inference on GPU, then switch to a CPU + instance for visualization and analysis — the persistent cache lets you + access results without re-computing. + + See [marimo caching docs](https://docs.marimo.io/api/caching/) for more. + """) + return + + +if __name__ == "__main__": + app.run() diff --git a/plugin/examples/with-cw.py b/plugin/examples/with-cw.py index d143c4d..c6d5825 100644 --- a/plugin/examples/with-cw.py +++ b/plugin/examples/with-cw.py @@ -47,7 +47,12 @@ def read_test_file(): print(f"File contents: {content}") else: print(f"Test file not found at {test_file}") - print("Available mounts:", os.listdir("/home/marimo/notebooks/mounts") if os.path.exists("/home/marimo/notebooks/mounts") else "none") + print( + "Available mounts:", + os.listdir("/home/marimo/notebooks/mounts") + if os.path.exists("/home/marimo/notebooks/mounts") + else "none", + ) return diff --git a/plugin/examples/with-rsync.py b/plugin/examples/with-rsync.py index 88d442d..f6ae690 100644 --- a/plugin/examples/with-rsync.py +++ b/plugin/examples/with-rsync.py @@ -13,8 +13,7 @@ @app.cell def check_sync(): - import os - import marimo as mo + return diff --git a/plugin/examples/with-sshfs.py b/plugin/examples/with-sshfs.py index f028c03..9074036 100644 --- a/plugin/examples/with-sshfs.py +++ b/plugin/examples/with-sshfs.py @@ -3,7 +3,7 @@ # /// # [tool.marimo.k8s] # storage = "1Gi" -# mounts = ["sshfs://user@host:/data"] +# mounts = ["sshfs://data"] import marimo diff --git a/plugin/kubectl_marimo/delete.py b/plugin/kubectl_marimo/delete.py index beb1e56..3d0de97 100644 --- a/plugin/kubectl_marimo/delete.py +++ b/plugin/kubectl_marimo/delete.py @@ -6,7 +6,7 @@ import click from .formats import parse_file -from .k8s import delete_resource, exec_in_pod +from .k8s import delete_resource, exec_in_pod, patch_resource from .resources import compute_hash, resource_name, detect_content_type from .swap import read_swap_file, delete_swap_file from .sync import sync_local_mounts @@ -17,7 +17,7 @@ def delete_notebook( namespace: str | None = None, force: bool = False, no_sync: bool = False, - keep_pvc: bool = False, + delete_pvc: bool = False, ) -> None: """Delete notebook deployment from cluster.""" path = Path(file_path) @@ -77,14 +77,19 @@ def delete_notebook( if meta.local_mounts: sync_local_mounts(meta.name, namespace, meta.local_mounts) - # Delete the MarimoNotebook resource - # Note: PVC is deleted via owner reference unless keep_pvc is set - if keep_pvc: - click.echo("Note: --keep-pvc requires manual PVC deletion prevention") - click.echo( - f' kubectl patch pvc -n {namespace} {name}-pvc -p \'{{"metadata":{{"ownerReferences":[]}}}}\'' - ) + # By default, preserve PVC by removing owner references before delete + # With --delete-pvc, skip patching so PVC is garbage collected + if not delete_pvc: + pvc_name = f"{name}-pvc" + patch_json = '{"metadata":{"ownerReferences":null}}' + if not patch_resource("pvc", pvc_name, namespace, patch_json): + click.echo( + "Warning: Could not patch PVC to remove owner references. " + "PVC may be deleted with the notebook.", + err=True, + ) + # Delete the MarimoNotebook resource if not delete_resource("marimos.marimo.io", name, namespace): sys.exit(1) diff --git a/plugin/kubectl_marimo/deploy.py b/plugin/kubectl_marimo/deploy.py index 6d145ef..699183e 100644 --- a/plugin/kubectl_marimo/deploy.py +++ b/plugin/kubectl_marimo/deploy.py @@ -72,7 +72,10 @@ def ensure_cw_credentials(namespace: str) -> bool: text=True, ) if result.returncode != 0: - click.echo(f"Warning: Failed to create cw-credentials secret: {result.stderr}", err=True) + click.echo( + f"Warning: Failed to create cw-credentials secret: {result.stderr}", + err=True, + ) return False click.echo(f"Created cw-credentials secret in namespace {namespace}") @@ -85,6 +88,224 @@ def has_cw_mounts(resource: dict) -> bool: return any(m.startswith("cw://") for m in mounts) +def has_sshfs_sidecars(resource: dict) -> bool: + """Check if resource has any sshfs sidecars.""" + sidecars = resource.get("spec", {}).get("sidecars", []) + return any(s.get("name", "").startswith("sshfs-") for s in sidecars) + + +def ensure_ssh_pubkey(namespace: str, dry_run: bool = False) -> bool: + """Create ssh-pubkey secret from public key. + + Args: + namespace: K8s namespace + dry_run: If True, only print what would be done + + Returns True if secret exists or was created (or would be in dry_run). + """ + # Check if secret already exists + if not dry_run: + result = subprocess.run( + ["kubectl", "get", "secret", "ssh-pubkey", "-n", namespace], + capture_output=True, + ) + if result.returncode == 0: + return True # Already exists + + # Check default locations for public key + default_paths = [ + Path.home() / ".ssh" / "id_rsa.pub", + Path.home() / ".ssh" / "id_ed25519.pub", + ] + + pub_key_path = None + for p in default_paths: + if p.exists(): + pub_key_path = p + break + + if pub_key_path: + # Found default key - confirm with user + if not click.confirm(f"Use SSH key at {pub_key_path}?"): + # User declined default - ask for path or suggest creation + key_input = click.prompt( + "Enter path to SSH public key (or press Enter to generate)", + default="", + ) + if key_input: + pub_key_path = Path(key_input).expanduser() + else: + # Generate new key + click.echo("Generating SSH key pair...") + new_key_path = Path.home() / ".ssh" / "id_ed25519" + subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-f", str(new_key_path), "-N", ""], + check=True, + ) + pub_key_path = Path(str(new_key_path) + ".pub") + else: + # No default key found + click.echo("No SSH key found at ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub") + key_input = click.prompt( + "Enter path to SSH public key (or press Enter to generate)", + default="", + ) + if key_input: + pub_key_path = Path(key_input).expanduser() + else: + # Generate new key + click.echo("Generating SSH key pair...") + new_key_path = Path.home() / ".ssh" / "id_ed25519" + subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-f", str(new_key_path), "-N", ""], + check=True, + ) + pub_key_path = Path(str(new_key_path) + ".pub") + + if not pub_key_path or not pub_key_path.exists(): + click.echo(f"Error: SSH key not found at {pub_key_path}", err=True) + return False + + if dry_run: + click.echo(f"# Would create ssh-pubkey secret from {pub_key_path}") + return True + + # Create secret + result = subprocess.run( + [ + "kubectl", + "create", + "secret", + "generic", + "ssh-pubkey", + "-n", + namespace, + f"--from-file=authorized_keys={pub_key_path}", + ], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + click.echo(f"Created ssh-pubkey secret in namespace {namespace}") + return True + else: + click.echo( + f"Warning: Failed to create ssh-pubkey secret: {result.stderr}", err=True + ) + return False + + +def setup_local_sshfs_mount( + name: str, + namespace: str, + remote_path: str, + local_mount: str, + ssh_port: int = 2222, +) -> subprocess.Popen | None: + """Set up local sshfs mount to pod using key-based auth. + + Args: + name: Resource name + namespace: Kubernetes namespace + remote_path: Path inside the pod to mount + local_mount: Local directory to mount to + ssh_port: SSH port to forward + + Returns: + Port-forward process, or None if setup failed + """ + # Check sshfs is installed + result = subprocess.run(["which", "sshfs"], capture_output=True) + sshfs_available = result.returncode == 0 + + # Find available local port for SSH + local_ssh_port = find_available_port(ssh_port) + + # Always start port-forward for SSH access (even without sshfs) + pf_proc = subprocess.Popen( + [ + "kubectl", + "port-forward", + "-n", + namespace, + f"svc/{name}", + f"{local_ssh_port}:2222", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # Wait for port-forward to be ready + time.sleep(2) + + if not sshfs_available: + click.echo("sshfs not installed - skipping local mount.", err=True) + click.echo("You can SSH directly to access files:", err=True) + click.echo(f" ssh -p {local_ssh_port} marimo@localhost", err=True) + return pf_proc # Return port-forward process so it stays alive + + # Create local mount directory + local_mount_path = Path(local_mount).expanduser().resolve() + local_mount_path.mkdir(parents=True, exist_ok=True) + + # Find user's private key + private_key = None + for key_name in ["id_rsa", "id_ed25519"]: + key_path = Path.home() / ".ssh" / key_name + if key_path.exists(): + private_key = key_path + break + + if not private_key: + click.echo("Warning: No SSH private key found, sshfs may fail", err=True) + private_key = Path.home() / ".ssh" / "id_rsa" # Try anyway + + # Mount via sshfs + sshfs_cmd = [ + "sshfs", + f"marimo@localhost:{remote_path}", + str(local_mount_path), + "-p", + str(local_ssh_port), + "-o", + f"IdentityFile={private_key}", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + ] + + result = subprocess.run(sshfs_cmd, capture_output=True, text=True) + if result.returncode != 0: + click.echo(f"Warning: sshfs mount failed: {result.stderr}", err=True) + pf_proc.terminate() + return None + + click.echo(f"Mounted pod:{remote_path} → {local_mount_path}") + return pf_proc + + +def cleanup_sshfs_mount(local_mount: str, pf_proc: subprocess.Popen | None) -> None: + """Unmount sshfs and stop port-forward. + + Args: + local_mount: Local mount path to unmount + pf_proc: Port-forward process to terminate + """ + local_mount_path = Path(local_mount).expanduser().resolve() + + # Unmount sshfs + if local_mount_path.exists(): + subprocess.run(["fusermount", "-u", str(local_mount_path)], capture_output=True) + # Also try umount on macOS + subprocess.run(["umount", str(local_mount_path)], capture_output=True) + + # Stop port-forward + if pf_proc: + pf_proc.terminate() + + def deploy_notebook( file_path: str, mode: str = "edit", @@ -134,7 +355,7 @@ def deploy_notebook( name = resource_name(file_path, frontmatter) # Build resource (separates local mounts from remote) - resource, local_mounts = build_marimo_notebook( + resource, rsync_mounts, sshfs_mounts = build_marimo_notebook( name=name, namespace=namespace, content=content, @@ -145,37 +366,56 @@ def deploy_notebook( if dry_run: click.echo(to_yaml(resource)) - if local_mounts: - click.echo("\n# Local mounts (handled by plugin via kubectl cp):") - for src, dest, scheme in local_mounts: + if rsync_mounts: + click.echo("\n# Rsync mounts (handled by plugin via kubectl cp):") + for src, dest, scheme in rsync_mounts: click.echo(f"# {src} → {dest}") + if sshfs_mounts: + click.echo("\n# SSHFS mounts (plugin mounts pod filesystem locally):") + for remote_path, local_mount in sshfs_mounts: + click.echo(f"# pod:{remote_path} → {local_mount}") + # Check if we'd need SSH pubkey + if has_sshfs_sidecars(resource): + ensure_ssh_pubkey(namespace, dry_run=True) return # Ensure cw-credentials secret exists if using cw:// mounts if has_cw_mounts(resource): ensure_cw_credentials(namespace) + # Ensure ssh-pubkey secret exists if using sshfs sidecars + if has_sshfs_sidecars(resource): + if not ensure_ssh_pubkey(namespace): + click.echo("Error: SSH pubkey required for sshfs mounts", err=True) + sys.exit(1) + # Apply to cluster if not apply_resource(resource): sys.exit(1) - # Handle local mounts - need to wait for pod ready first - if local_mounts: + # Handle rsync mounts - need to wait for pod ready first + if rsync_mounts: click.echo(f"Waiting for {name} to be ready for local sync...") if wait_for_ready(name, namespace): - for src, dest, _scheme in local_mounts: + for src, dest, _scheme in rsync_mounts: sync_local_source(name, namespace, src, dest) else: click.echo("Warning: Pod not ready, skipping local sync", err=True) # Create swap file for tracking deployment file_hash = compute_hash(content) if content else "" - # Convert local_mounts to serializable format - mounts_data = ( - [{"local": src, "remote": dest} for src, dest, _ in local_mounts] - if local_mounts - else None - ) + # Convert mounts to serializable format + mounts_data = None + if rsync_mounts: + mounts_data = [{"local": src, "remote": dest} for src, dest, _ in rsync_mounts] + if sshfs_mounts: + mounts_data = mounts_data or [] + mounts_data.extend( + [ + {"local": local, "remote": remote, "type": "sshfs"} + for remote, local in sshfs_mounts + ] + ) meta = create_swap_meta( name=name, namespace=namespace, @@ -195,14 +435,18 @@ def deploy_notebook( if headless: # Print access info for manual port-forward - print_access_info(name, namespace, mode, frontmatter) + print_access_info(name, namespace, mode, frontmatter, sshfs_mounts) else: # Auto port-forward and open browser - open_notebook(name, namespace, port, file_path) + open_notebook(name, namespace, port, file_path, sshfs_mounts) def print_access_info( - name: str, namespace: str, mode: str, frontmatter: dict | None + name: str, + namespace: str, + mode: str, + frontmatter: dict | None, + sshfs_mounts: list[tuple[str, str]] | None = None, ) -> None: """Print helpful access information after deploy.""" port = 2718 @@ -230,8 +474,23 @@ def print_access_info( click.echo() click.echo("Running in read-only app mode.") + # Print sshfs mount instructions + if sshfs_mounts: + click.echo() + click.echo("To mount pod filesystem locally:") + click.echo(f" kubectl port-forward -n {namespace} svc/{name} 2222:2222 &") + for remote_path, local_mount in sshfs_mounts: + click.echo(f" mkdir -p {local_mount}") + click.echo(f" sshfs marimo@localhost:{remote_path} {local_mount} -p 2222") + -def open_notebook(name: str, namespace: str, port: int, file_path: str) -> None: +def open_notebook( + name: str, + namespace: str, + port: int, + file_path: str, + sshfs_mounts: list[tuple[str, str]] | None = None, +) -> None: """Port-forward and open browser. Args: @@ -239,12 +498,21 @@ def open_notebook(name: str, namespace: str, port: int, file_path: str) -> None: namespace: Kubernetes namespace port: Service port file_path: Path to local notebook file (for sync on exit) + sshfs_mounts: List of (remote_path, local_mount) for sshfs mounts """ # Wait for pod ready click.echo(f"Waiting for {name} to be ready...") if not wait_for_ready(name, namespace): click.echo("Warning: Pod may not be ready, continuing anyway...", err=True) + # Set up local sshfs mounts if any + sshfs_procs: list[tuple[str, subprocess.Popen | None]] = [] + if sshfs_mounts: + click.echo("Setting up sshfs mounts...") + for remote_path, local_mount in sshfs_mounts: + pf_proc = setup_local_sshfs_mount(name, namespace, remote_path, local_mount) + sshfs_procs.append((local_mount, pf_proc)) + # Extract access token from pod logs (retry a few times as marimo may still be starting) token = None for _ in range(5): @@ -281,6 +549,12 @@ def open_notebook(name: str, namespace: str, port: int, file_path: str) -> None: ] ) except KeyboardInterrupt: + # Clean up sshfs mounts + if sshfs_procs: + click.echo("\nCleaning up sshfs mounts...") + for local_mount, pf_proc in sshfs_procs: + cleanup_sshfs_mount(local_mount, pf_proc) + click.echo("\nSyncing changes...") try: sync_notebook(file_path, namespace=namespace, force=True) diff --git a/plugin/kubectl_marimo/k8s.py b/plugin/kubectl_marimo/k8s.py index 58ebb48..13b1b21 100644 --- a/plugin/kubectl_marimo/k8s.py +++ b/plugin/kubectl_marimo/k8s.py @@ -100,6 +100,39 @@ def get_pod_logs(pod_name: str, namespace: str) -> tuple[bool, str]: return False, "kubectl not found in PATH" +def patch_resource( + kind: str, + name: str, + namespace: str, + patch: str, +) -> bool: + """Patch a Kubernetes resource using kubectl. + + Returns True on success, False on failure. + """ + cmd = [ + "kubectl", + "patch", + kind, + name, + "-n", + namespace, + "--type=merge", + "-p", + patch, + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"Error: {result.stderr}", file=sys.stderr) + return False + print(result.stdout, end="") + return True + except FileNotFoundError: + print("Error: kubectl not found in PATH", file=sys.stderr) + return False + + def get_resource( kind: str, name: str, diff --git a/plugin/kubectl_marimo/main.py b/plugin/kubectl_marimo/main.py index b96fed3..bfac99e 100644 --- a/plugin/kubectl_marimo/main.py +++ b/plugin/kubectl_marimo/main.py @@ -108,15 +108,18 @@ def sync(file: str, namespace: str | None, force: bool): "-n", "--namespace", help="Kubernetes namespace (default: from swap file)" ) @click.option( - "--keep-pvc", is_flag=True, help="Keep PersistentVolumeClaim (preserve data)" + "--delete-pvc", + is_flag=True, + help="Also delete PersistentVolumeClaim (destroys data)", ) @click.option("--no-sync", is_flag=True, help="Delete without syncing changes back") -def delete(file: str, namespace: str | None, keep_pvc: bool, no_sync: bool): +def delete(file: str, namespace: str | None, delete_pvc: bool, no_sync: bool): """Sync changes, then delete cluster resources. FILE is the local notebook that was previously deployed. + PVC is preserved by default to protect your data. """ - delete_notebook(file, namespace=namespace, keep_pvc=keep_pvc, no_sync=no_sync) + delete_notebook(file, namespace=namespace, delete_pvc=delete_pvc, no_sync=no_sync) @cli.command() diff --git a/plugin/kubectl_marimo/resources.py b/plugin/kubectl_marimo/resources.py index 13bc87b..52e4a3f 100644 --- a/plugin/kubectl_marimo/resources.py +++ b/plugin/kubectl_marimo/resources.py @@ -1,116 +1,96 @@ """Generate Kubernetes resources for MarimoNotebook.""" import hashlib +import os import re from pathlib import Path from typing import Any -def parse_mount_uri(uri: str) -> tuple[str, str, str | None, str | None]: - """Parse mount URI in Docker-style format. +# Default SSH image, configurable via environment +SSH_IMAGE = os.environ.get("SSH_IMAGE", "linuxserver/openssh-server:latest") - Format: ://[user@host:][:mount_point] - Local detection: no '@' in URI (supports relative and absolute paths) - Remote detection: has '@' (user@host format) - - Mount point handling: - - Absolute (/data) → use as-is - - Relative (data) → prepend /home/marimo/notebooks/ - - Returns: (scheme, source_path, user_host, mount_point) +def parse_mount_uri(uri: str) -> tuple[str, str]: + """Parse mount URI into (scheme, path). Examples: - rsync://examples:data → ('rsync', 'examples', None, '/home/marimo/notebooks/data') - rsync://examples:/data → ('rsync', 'examples', None, '/data') - rsync:///abs/path:/mnt → ('rsync', '/abs/path', None, '/mnt') - rsync://relative/path → ('rsync', 'relative/path', None, None) - rsync://user@host:/data:/mnt → ('rsync', '/data', 'user@host', '/mnt') - rsync://user@host:/data → ('rsync', '/data', 'user@host', None) - sshfs://user@host:/remote:/mnt → ('sshfs', '/remote', 'user@host', '/mnt') + sshfs:///path → ('sshfs', '/path') + rsync://./data → ('rsync', './data') + cw://bucket/path → ('cw', 'bucket/path') """ - # Extract scheme match = re.match(r"^(\w+)://(.*)$", uri) if not match: raise ValueError(f"Invalid mount URI: {uri}") - - scheme = match.group(1) - remainder = match.group(2) - - # Local mount: rsync scheme with no '@' means local path (relative or absolute) - # rsync://examples:/data or rsync:///abs/path:/mnt - # Note: only rsync supports local paths; other schemes (sshfs, cw) are always remote - if scheme == "rsync" and "@" not in remainder: - # Split on last ':' to get source and optional mount point - # examples:data → source=examples, mount=/home/marimo/notebooks/data - # examples:/data → source=examples, mount=/data - # /abs/path:/mnt → source=/abs/path, mount=/mnt - parts = remainder.rsplit(":", 1) - if len(parts) == 2 and parts[1]: - source, mount = parts - # Relative mount point → prepend /home/marimo/notebooks/ - if not mount.startswith("/"): - mount = f"/home/marimo/notebooks/{mount}" - return (scheme, source, None, mount) - return (scheme, remainder, None, None) - - # Remote mount: rsync://user@host:/path or rsync://user@host:/path:/mount - # Format: user@host:/source or user@host:/source:/mount - # Split at first : that follows the host - colon_idx = remainder.find(":/") - if colon_idx == -1: - raise ValueError(f"Invalid remote mount URI: {uri}") - - user_host = remainder[:colon_idx] - path_part = remainder[colon_idx + 1 :] # includes leading / - - # Check for mount point (another : followed by /) - # /data:/mnt → source=/data, mount=/mnt - parts = path_part.rsplit(":", 1) - if len(parts) == 2 and parts[1].startswith("/"): - return (scheme, parts[0], user_host, parts[1]) - return (scheme, path_part, user_host, None) - - -def is_local_mount(uri: str) -> bool: - """Check if mount URI is local (no host). - - Local: rsync:// with no '@' (relative or absolute paths) - Remote: has '@' (user@host:/path format) or non-rsync scheme - """ - _, _, user_host, _ = parse_mount_uri(uri) - return user_host is None + return (match.group(1), match.group(2)) def filter_mounts( mounts: list[str], -) -> tuple[list[str], list[tuple[str, str, str | None]]]: - """Separate local and remote mounts. +) -> tuple[list[str], list[tuple[str, str, str]], list[tuple[str, str]]]: + """Categorize mounts by scheme. Returns: - (remote_mounts, local_mounts) - - remote_mounts: URIs to pass to CRD (operator handles) - - local_mounts: list of (source_path, mount_point, scheme) tuples (plugin handles) + (cw_mounts, rsync_mounts, sshfs_mounts) + - cw_mounts: URIs to pass to CRD (operator handles via s3fs sidecar) + - rsync_mounts: list of (source_path, mount_point, scheme) for kubectl cp + - sshfs_mounts: list of (remote_path, local_mount) for local sshfs mount """ - remote_mounts = [] - local_mounts = [] + cw_mounts = [] + rsync_mounts = [] + sshfs_mounts = [] for i, uri in enumerate(mounts): try: - scheme, source_path, user_host, mount_point = parse_mount_uri(uri) - - if user_host is None: - # Local mount - plugin handles via kubectl cp - default_mount = f"/home/marimo/notebooks/mounts/local-{i}" - local_mounts.append((source_path, mount_point or default_mount, scheme)) + scheme, path = parse_mount_uri(uri) + + if scheme == "cw": + # CoreWeave S3 - operator handles + cw_mounts.append(uri) + elif scheme == "rsync": + # Local rsync - plugin handles via kubectl cp + # Parse optional mount point: rsync://./data:/mnt/data + parts = path.rsplit(":", 1) + if len(parts) == 2 and parts[1].startswith("/"): + source, mount = parts + else: + source = path + mount = f"/home/marimo/notebooks/mounts/local-{i}" + rsync_mounts.append((source, mount, scheme)) + elif scheme == "sshfs": + # Local sshfs - plugin runs sshfs locally to mount pod + # sshfs:///home/marimo/notebooks means mount pod's /home/marimo/notebooks locally + remote_path = path if path.startswith("/") else f"/{path}" + local_mount = f"./marimo-mount-{i}" + sshfs_mounts.append((remote_path, local_mount)) else: - # Remote mount - pass to CRD as-is - remote_mounts.append(uri) + # Unknown scheme - pass through to operator + cw_mounts.append(uri) except ValueError: - # Unknown scheme (e.g., cw://) - pass through to operator - remote_mounts.append(uri) + # Invalid URI - pass through to operator + cw_mounts.append(uri) + + return cw_mounts, rsync_mounts, sshfs_mounts - return remote_mounts, local_mounts + +def build_ssh_sidecar(index: int) -> dict[str, Any]: + """Build SSH sidecar spec for key-based auth. + + The sidecar runs an SSH server that accepts connections using + the user's public key (stored in ssh-pubkey secret). + """ + return { + "name": f"sshfs-{index}", + "image": SSH_IMAGE, + "exposePort": 2222, + "env": [ + {"name": "PASSWORD_ACCESS", "value": "false"}, + {"name": "USER_NAME", "value": "marimo"}, + {"name": "PUID", "value": "1000"}, + {"name": "PGID", "value": "1000"}, + {"name": "PUBLIC_KEY_FILE", "value": "/config/ssh-pubkey/authorized_keys"}, + ], + } def compute_hash(content: str) -> str: @@ -180,7 +160,7 @@ def build_marimo_notebook( frontmatter: dict[str, Any] | None = None, mode: str = "edit", source: str | None = None, -) -> tuple[dict[str, Any], list[tuple[str, str, str | None]]]: +) -> tuple[dict[str, Any], list[tuple[str, str, str]], list[tuple[str, str]]]: """Build MarimoNotebook custom resource. Args: @@ -189,12 +169,13 @@ def build_marimo_notebook( content: Notebook content (None for directory mode) frontmatter: Parsed frontmatter configuration mode: Marimo mode - "edit" or "run" - source: Data source URI (rsync://, sshfs://) + source: Data source URI (rsync://, sshfs://, cw://) Returns: - (resource, local_mounts) + (resource, rsync_mounts, sshfs_mounts) - resource: CRD dict to apply to cluster - - local_mounts: list of (source_path, mount_point, scheme) for plugin to handle + - rsync_mounts: list of (source_path, mount_point, scheme) for kubectl cp + - sshfs_mounts: list of (remote_path, local_mount) for local sshfs """ spec: dict[str, Any] = { "mode": mode, @@ -223,6 +204,10 @@ def build_marimo_notebook( if "env" in frontmatter: spec["env"] = parse_env(frontmatter["env"]) + # Resources (CPU, memory, GPU) + if "resources" in frontmatter: + spec["resources"] = frontmatter["resources"] + # Collect mounts from --source and frontmatter all_mounts = [] if source: @@ -230,12 +215,20 @@ def build_marimo_notebook( if frontmatter and "mounts" in frontmatter: all_mounts.extend(frontmatter["mounts"]) - # Separate local (plugin handles) from remote (operator handles) - local_mounts: list[tuple[str, str, str | None]] = [] + # Categorize mounts by scheme + rsync_mounts: list[tuple[str, str, str]] = [] + sshfs_mounts: list[tuple[str, str]] = [] if all_mounts: - remote_mounts, local_mounts = filter_mounts(all_mounts) - if remote_mounts: - spec["mounts"] = remote_mounts + cw_mounts, rsync_mounts, sshfs_mounts = filter_mounts(all_mounts) + if cw_mounts: + spec["mounts"] = cw_mounts + + # Add SSH sidecars for sshfs mounts + sidecars = [] + for i, _ in enumerate(sshfs_mounts): + sidecars.append(build_ssh_sidecar(i)) + if sidecars: + spec["sidecars"] = sidecars resource = { "apiVersion": "marimo.io/v1alpha1", @@ -246,7 +239,7 @@ def build_marimo_notebook( }, "spec": spec, } - return resource, local_mounts + return resource, rsync_mounts, sshfs_mounts def to_yaml(resource: dict[str, Any]) -> str: diff --git a/plugin/tests/test_delete.py b/plugin/tests/test_delete.py new file mode 100644 index 0000000..3bb7bd9 --- /dev/null +++ b/plugin/tests/test_delete.py @@ -0,0 +1,131 @@ +"""Tests for delete module.""" + +import pytest + +from kubectl_marimo.delete import delete_notebook + + +class TestDeleteNotebook: + """Tests for delete_notebook function.""" + + @pytest.fixture + def notebook_file(self, tmp_path): + """Create a temporary notebook file.""" + nb = tmp_path / "test_notebook.py" + nb.write_text( + "import marimo\napp = marimo.App()\n@app.cell\ndef _():\n pass" + ) + return nb + + @pytest.fixture + def mock_k8s(self, mocker): + """Mock kubernetes operations.""" + mocks = { + "delete_resource": mocker.patch( + "kubectl_marimo.delete.delete_resource", return_value=True + ), + "patch_resource": mocker.patch( + "kubectl_marimo.delete.patch_resource", return_value=True + ), + "exec_in_pod": mocker.patch( + "kubectl_marimo.delete.exec_in_pod", + return_value=(False, "pod not found"), + ), + } + return mocks + + @pytest.fixture + def mock_swap(self, mocker): + """Mock swap file operations.""" + mocker.patch("kubectl_marimo.delete.read_swap_file", return_value=None) + mocker.patch("kubectl_marimo.delete.delete_swap_file") + + def test_preserves_pvc_by_default(self, notebook_file, mock_k8s, mock_swap): + """By default, patches PVC to remove owner reference before delete.""" + delete_notebook(str(notebook_file), namespace="default", no_sync=True) + + # Should patch PVC to remove owner references + mock_k8s["patch_resource"].assert_called_once() + call_args = mock_k8s["patch_resource"].call_args + assert call_args[0][0] == "pvc" # kind + assert "test-notebook-pvc" in call_args[0][1] # name + assert call_args[0][2] == "default" # namespace + assert "ownerReferences" in call_args[0][3] # patch contains ownerReferences + + # Should still delete the MarimoNotebook + mock_k8s["delete_resource"].assert_called_once() + + def test_delete_pvc_flag_skips_patch(self, notebook_file, mock_k8s, mock_swap): + """With --delete-pvc, skips patching so PVC is garbage collected.""" + delete_notebook( + str(notebook_file), namespace="default", delete_pvc=True, no_sync=True + ) + + # Should NOT patch PVC + mock_k8s["patch_resource"].assert_not_called() + + # Should still delete the MarimoNotebook + mock_k8s["delete_resource"].assert_called_once() + + def test_continues_if_patch_fails(self, notebook_file, mock_k8s, mock_swap, mocker): + """Continues with delete even if PVC patch fails.""" + mock_k8s["patch_resource"].return_value = False + mock_echo = mocker.patch("kubectl_marimo.delete.click.echo") + + delete_notebook(str(notebook_file), namespace="default", no_sync=True) + + # Should warn about patch failure + warning_calls = [c for c in mock_echo.call_args_list if "Warning" in str(c)] + assert len(warning_calls) > 0 + + # Should still attempt delete + mock_k8s["delete_resource"].assert_called_once() + + +class TestPatchResource: + """Tests for patch_resource function.""" + + def test_patch_resource_success(self, mocker): + """Successfully patches a resource.""" + from kubectl_marimo.k8s import patch_resource + + mock_run = mocker.patch("kubectl_marimo.k8s.subprocess.run") + mock_run.return_value.returncode = 0 + + result = patch_resource( + "pvc", "test-pvc", "default", '{"metadata":{"ownerReferences":null}}' + ) + + assert result is True + args = mock_run.call_args[0][0] + assert "kubectl" in args + assert "patch" in args + assert "pvc" in args + assert "test-pvc" in args + assert "-n" in args + assert "default" in args + assert "--type=merge" in args + + def test_patch_resource_failure(self, mocker): + """Returns False on patch failure.""" + from kubectl_marimo.k8s import patch_resource + + mock_run = mocker.patch("kubectl_marimo.k8s.subprocess.run") + mock_run.return_value.returncode = 1 + mock_run.return_value.stderr = "resource not found" + + result = patch_resource("pvc", "test-pvc", "default", "{}") + + assert result is False + + def test_patch_resource_kubectl_not_found(self, mocker): + """Returns False when kubectl not in PATH.""" + from kubectl_marimo.k8s import patch_resource + + mocker.patch( + "kubectl_marimo.k8s.subprocess.run", side_effect=FileNotFoundError() + ) + + result = patch_resource("pvc", "test-pvc", "default", "{}") + + assert result is False diff --git a/plugin/tests/test_resources.py b/plugin/tests/test_resources.py index 036e90e..c274f83 100644 --- a/plugin/tests/test_resources.py +++ b/plugin/tests/test_resources.py @@ -10,8 +10,8 @@ detect_content_type, parse_env, parse_mount_uri, - is_local_mount, filter_mounts, + build_ssh_sidecar, ) @@ -64,7 +64,7 @@ def test_frontmatter_takes_precedence(self): class TestBuildMarimoNotebook: def test_basic(self): - resource, local_mounts = build_marimo_notebook( + resource, rsync_mounts, sshfs_mounts = build_marimo_notebook( name="test-notebook", namespace="default", content="# test content", @@ -78,10 +78,11 @@ def test_basic(self): assert resource["spec"]["mode"] == "edit" # Default storage should be 1Gi assert resource["spec"]["storage"]["size"] == "1Gi" - assert local_mounts == [] + assert rsync_mounts == [] + assert sshfs_mounts == [] def test_with_image(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -90,7 +91,7 @@ def test_with_image(self): assert resource["spec"]["image"] == "custom:latest" def test_with_port(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -99,7 +100,7 @@ def test_with_port(self): assert resource["spec"]["port"] == 8080 def test_with_storage(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -108,7 +109,7 @@ def test_with_storage(self): assert resource["spec"]["storage"]["size"] == "5Gi" def test_auth_none(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -117,7 +118,7 @@ def test_auth_none(self): assert resource["spec"]["auth"] == {} def test_mode_edit(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -126,7 +127,7 @@ def test_mode_edit(self): assert resource["spec"]["mode"] == "edit" def test_mode_run(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -134,8 +135,8 @@ def test_mode_run(self): ) assert resource["spec"]["mode"] == "run" - def test_source_adds_mount(self): - resource, _ = build_marimo_notebook( + def test_source_adds_cw_mount(self): + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -143,28 +144,17 @@ def test_source_adds_mount(self): ) assert resource["spec"]["mounts"] == ["cw://bucket/data"] - def test_frontmatter_mounts(self): - resource, _ = build_marimo_notebook( + def test_frontmatter_cw_mounts(self): + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", - frontmatter={"mounts": ["cw://bucket1", "sshfs://user@host:/path"]}, + frontmatter={"mounts": ["cw://bucket1", "cw://bucket2"]}, ) - assert resource["spec"]["mounts"] == ["cw://bucket1", "sshfs://user@host:/path"] - - def test_source_and_frontmatter_mounts_combined(self): - resource, _ = build_marimo_notebook( - name="test", - namespace="default", - content="content", - frontmatter={"mounts": ["cw://bucket1"]}, - source="sshfs://user@host:/path", - ) - # Source should come first, then frontmatter mounts - assert resource["spec"]["mounts"] == ["sshfs://user@host:/path", "cw://bucket1"] + assert resource["spec"]["mounts"] == ["cw://bucket1", "cw://bucket2"] def test_frontmatter_env(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content="content", @@ -177,7 +167,7 @@ def test_frontmatter_env(self): assert debug_var["value"] == "true" def test_content_none_for_directory(self): - resource, _ = build_marimo_notebook( + resource, _, _ = build_marimo_notebook( name="test", namespace="default", content=None, # Directory mode @@ -187,44 +177,65 @@ def test_content_none_for_directory(self): assert resource["spec"]["mode"] == "edit" assert resource["spec"]["storage"]["size"] == "1Gi" - def test_local_mount_filtered(self): - """Local mounts (rsync:///path) should be returned separately, not in CRD.""" - resource, local_mounts = build_marimo_notebook( + def test_rsync_mount_filtered(self): + """Rsync mounts should be returned separately, not in CRD.""" + resource, rsync_mounts, _ = build_marimo_notebook( name="test", namespace="default", content="content", - source="rsync:///local/data:/mnt/data", + source="rsync://./local/data:/mnt/data", ) - # Local mounts should NOT be in CRD + # Rsync mounts should NOT be in CRD assert "mounts" not in resource["spec"] # Local mount should be returned separately - assert len(local_mounts) == 1 - src, dest, scheme = local_mounts[0] - assert src == "/local/data" + assert len(rsync_mounts) == 1 + src, dest, scheme = rsync_mounts[0] + assert src == "./local/data" assert dest == "/mnt/data" assert scheme == "rsync" - def test_mixed_local_and_remote_mounts(self): - """Mix of local and remote mounts should be separated correctly.""" - resource, local_mounts = build_marimo_notebook( + def test_sshfs_mount_adds_sidecar(self): + """SSHFS mounts should add SSH sidecar and return local mount info.""" + resource, _, sshfs_mounts = build_marimo_notebook( + name="test", + namespace="default", + content="content", + source="sshfs:///home/marimo/notebooks", + ) + # Should have sidecar added + assert "sidecars" in resource["spec"] + assert len(resource["spec"]["sidecars"]) == 1 + sidecar = resource["spec"]["sidecars"][0] + assert sidecar["name"] == "sshfs-0" + assert sidecar["exposePort"] == 2222 + # Should return sshfs mount info + assert len(sshfs_mounts) == 1 + remote_path, local_mount = sshfs_mounts[0] + assert remote_path == "/home/marimo/notebooks" + + def test_mixed_mount_schemes(self): + """Mix of mount schemes should be handled correctly.""" + resource, rsync_mounts, sshfs_mounts = build_marimo_notebook( name="test", namespace="default", content="content", frontmatter={ "mounts": [ - "rsync:///local/path", # Local (no host) - "rsync://user@host:/remote", # Remote + "rsync://./local/path", # Rsync - plugin handles + "sshfs:///data", # SSHFS - plugin handles + "cw://bucket/path", # CW - operator handles ] }, ) - # Only remote mount should be in CRD - assert resource["spec"]["mounts"] == ["rsync://user@host:/remote"] - # Local mount should be separate - assert len(local_mounts) == 1 - src, dest, scheme = local_mounts[0] - assert src == "/local/path" - assert dest == "/home/marimo/notebooks/mounts/local-0" # Default - assert scheme == "rsync" + # Only CW mount should be in CRD mounts + assert resource["spec"]["mounts"] == ["cw://bucket/path"] + # Should have sshfs sidecar + assert "sidecars" in resource["spec"] + assert len(resource["spec"]["sidecars"]) == 1 + # Rsync should be separate + assert len(rsync_mounts) == 1 + # SSHFS should be separate + assert len(sshfs_mounts) == 1 class TestParseEnv: @@ -276,124 +287,96 @@ def test_empty_is_python(self): class TestParseMountUri: - def test_local_absolute(self): - """Triple slash = absolute local path.""" - scheme, source, user_host, mount = parse_mount_uri("rsync:///local/data") - assert scheme == "rsync" - assert source == "/local/data" - assert user_host is None - assert mount is None - - def test_local_absolute_with_mount(self): - scheme, source, user_host, mount = parse_mount_uri( - "rsync:///local/data:/mnt/data" - ) - assert scheme == "rsync" - assert source == "/local/data" - assert user_host is None - assert mount == "/mnt/data" - - def test_local_relative(self): - """Double slash without @ = relative local path.""" - scheme, source, user_host, mount = parse_mount_uri("rsync://examples") - assert scheme == "rsync" - assert source == "examples" - assert user_host is None - assert mount is None - - def test_local_relative_with_mount(self): - """rsync://examples:/data = relative 'examples' to '/data'.""" - scheme, source, user_host, mount = parse_mount_uri("rsync://examples:/data") - assert scheme == "rsync" - assert source == "examples" - assert user_host is None - assert mount == "/data" - - def test_local_relative_mount_point(self): - """rsync://examples:data = relative mount → /home/marimo/notebooks/data.""" - scheme, source, user_host, mount = parse_mount_uri("rsync://examples:data") - assert scheme == "rsync" - assert source == "examples" - assert user_host is None - assert mount == "/home/marimo/notebooks/data" - - def test_local_relative_path_with_mount(self): - """rsync://path/to/dir:/mnt = relative path with subdirs.""" - scheme, source, user_host, mount = parse_mount_uri("rsync://path/to/dir:/mnt") - assert scheme == "rsync" - assert source == "path/to/dir" - assert user_host is None - assert mount == "/mnt" + def test_sshfs_absolute(self): + """sshfs:///path = local sshfs mount.""" + scheme, path = parse_mount_uri("sshfs:///home/marimo/notebooks") + assert scheme == "sshfs" + assert path == "/home/marimo/notebooks" - def test_remote_simple(self): - scheme, source, user_host, mount = parse_mount_uri( - "rsync://user@host:/remote/path" - ) + def test_rsync_relative(self): + """rsync://./path = relative local path.""" + scheme, path = parse_mount_uri("rsync://./local/data") assert scheme == "rsync" - assert source == "/remote/path" - assert user_host == "user@host" - assert mount is None + assert path == "./local/data" - def test_remote_with_mount(self): - scheme, source, user_host, mount = parse_mount_uri( - "rsync://user@host:/remote:/mnt/custom" - ) + def test_rsync_with_mount(self): + """rsync://./local/data:/mnt/data = rsync with mount point.""" + scheme, path = parse_mount_uri("rsync://./local/data:/mnt/data") assert scheme == "rsync" - assert source == "/remote" - assert user_host == "user@host" - assert mount == "/mnt/custom" + assert path == "./local/data:/mnt/data" - def test_sshfs_scheme(self): - scheme, source, user_host, mount = parse_mount_uri("sshfs://user@host:/data") - assert scheme == "sshfs" - assert source == "/data" - assert user_host == "user@host" + def test_cw_bucket(self): + """cw://bucket/path = CoreWeave S3.""" + scheme, path = parse_mount_uri("cw://mybucket/data") + assert scheme == "cw" + assert path == "mybucket/data" def test_invalid_uri(self): with pytest.raises(ValueError): parse_mount_uri("invalid") -class TestIsLocalMount: - def test_local_absolute(self): - assert is_local_mount("rsync:///local/path") is True - assert is_local_mount("rsync:///local/path:/mnt") is True - - def test_local_relative(self): - assert is_local_mount("rsync://examples") is True - assert is_local_mount("rsync://examples:/data") is True - assert is_local_mount("rsync://examples:data") is True # Relative mount point - assert is_local_mount("rsync://path/to/dir:/mnt") is True - - def test_remote_mount(self): - assert is_local_mount("rsync://user@host:/path") is False - assert is_local_mount("sshfs://user@host:/path") is False - - class TestFilterMounts: - def test_separates_local_and_remote(self): + def test_separates_schemes(self): + """Mounts should be categorized by scheme.""" mounts = [ - "rsync:///local/path", - "rsync://user@host:/remote", - "sshfs://user@host:/data", + "rsync://./local/path", + "sshfs:///data", + "cw://bucket/path", ] - remote, local = filter_mounts(mounts) - assert remote == ["rsync://user@host:/remote", "sshfs://user@host:/data"] - assert len(local) == 1 - assert local[0][0] == "/local/path" # source - assert local[0][2] == "rsync" # scheme - - def test_local_default_mount_point(self): - mounts = ["rsync:///path1", "rsync:///path2"] - remote, local = filter_mounts(mounts) - assert remote == [] - assert len(local) == 2 + cw_mounts, rsync_mounts, sshfs_mounts = filter_mounts(mounts) + assert cw_mounts == ["cw://bucket/path"] + assert len(rsync_mounts) == 1 + assert rsync_mounts[0][0] == "./local/path" # source + assert rsync_mounts[0][2] == "rsync" # scheme + assert len(sshfs_mounts) == 1 + assert sshfs_mounts[0][0] == "/data" # remote path + + def test_rsync_default_mount_point(self): + mounts = ["rsync://./path1", "rsync://./path2"] + cw, rsync, sshfs = filter_mounts(mounts) + assert cw == [] + assert len(rsync) == 2 # Check default mount points use index - assert local[0][1] == "/home/marimo/notebooks/mounts/local-0" - assert local[1][1] == "/home/marimo/notebooks/mounts/local-1" - - def test_local_custom_mount_point(self): - mounts = ["rsync:///src:/dest"] - remote, local = filter_mounts(mounts) - assert local[0][0] == "/src" - assert local[0][1] == "/dest" + assert rsync[0][1] == "/home/marimo/notebooks/mounts/local-0" + assert rsync[1][1] == "/home/marimo/notebooks/mounts/local-1" + + def test_rsync_custom_mount_point(self): + mounts = ["rsync://./src:/dest"] + cw, rsync, sshfs = filter_mounts(mounts) + assert rsync[0][0] == "./src" + assert rsync[0][1] == "/dest" + + def test_sshfs_mount_info(self): + mounts = ["sshfs:///home/marimo/notebooks"] + cw, rsync, sshfs = filter_mounts(mounts) + assert len(sshfs) == 1 + remote_path, local_mount = sshfs[0] + assert remote_path == "/home/marimo/notebooks" + assert local_mount.startswith("./marimo-mount-") + + def test_unknown_scheme_passes_through(self): + """Unknown schemes should pass through to operator.""" + mounts = ["nfs://server/path"] + cw, rsync, sshfs = filter_mounts(mounts) + assert cw == ["nfs://server/path"] # Unknown goes to operator + assert rsync == [] + assert sshfs == [] + + +class TestBuildSshSidecar: + def test_basic(self): + sidecar = build_ssh_sidecar(0) + assert sidecar["name"] == "sshfs-0" + assert sidecar["exposePort"] == 2222 + assert any( + e["name"] == "PASSWORD_ACCESS" and e["value"] == "false" + for e in sidecar["env"] + ) + assert any( + e["name"] == "USER_NAME" and e["value"] == "marimo" for e in sidecar["env"] + ) + + def test_index(self): + sidecar = build_ssh_sidecar(3) + assert sidecar["name"] == "sshfs-3" diff --git a/plugin/uv.lock b/plugin/uv.lock index bbbdb4a..ccf8400 100644 --- a/plugin/uv.lock +++ b/plugin/uv.lock @@ -185,7 +185,7 @@ wheels = [ [[package]] name = "kubectl-marimo" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" },