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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ go.work
*.swo
*~

__marimo__/*
__pycache__/*
*/__marimo__/*
*/__pycache__/*
*.pyc

# Kubeconfig might contain secrets
Expand Down
1 change: 1 addition & 0 deletions pkg/resources/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ func buildResourceRequirements(spec *marimov1alpha1.ResourcesSpec) corev1.Resour

// applyPodOverrides merges overrides into base using strategic merge patch.
func applyPodOverrides(base, overrides corev1.PodSpec) corev1.PodSpec {
// Clear empty slices to prevent them from replacing base values.
baseJSON, err := json.Marshal(base)
if err != nil {
return base
Expand Down
2 changes: 1 addition & 1 deletion plugin/examples/gpu-getting-started.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import marimo

__generated_with = "0.18.4"
__generated_with = "0.19.2"
app = marimo.App()


Expand Down
6 changes: 5 additions & 1 deletion plugin/kubectl_marimo/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ def deploy_notebook(
name = resource_name(file_path, frontmatter)

# Build resource (separates local mounts from remote)
resource, rsync_mounts, sshfs_mounts = build_marimo_notebook(
resource, rsync_mounts, sshfs_mounts, warnings = build_marimo_notebook(
name=name,
namespace=namespace,
content=content,
Expand All @@ -452,6 +452,10 @@ def deploy_notebook(
source=source,
)

# Show warnings about unknown fields or other issues
for warning in warnings:
click.echo(click.style(f"Warning: {warning}", fg="yellow"), err=True)

if dry_run:
click.echo(to_yaml(resource))
if rsync_mounts:
Expand Down
22 changes: 21 additions & 1 deletion plugin/kubectl_marimo/formats/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ def extract_pep723_metadata(content: str) -> dict[str, Any] | None:
# storage = "5Gi"
"""
# Look for PEP 723 script block
pattern = r"# /// script\n((?:# .*\n)*?)# ///"
# Pattern allows empty comment lines (# or #\n) as well as # followed by content
pattern = r"# /// script\n((?:#(?: .*)?\n)*?)# ///"
match = re.search(pattern, content)

if not match:
Expand Down Expand Up @@ -92,6 +93,25 @@ def extract_pep723_metadata(content: str) -> dict[str, Any] | None:
if env:
metadata["env"] = env

# Look for marimo k8s nodeSelector config
node_selector_pattern = r"# \[tool\.marimo\.k8s\.nodeSelector\]\n((?:# .*\n)*)"
node_selector_match = re.search(node_selector_pattern, content)
if node_selector_match:
node_selector = {}
for line in node_selector_match.group(1).split("\n"):
line = line.lstrip("# ").strip()
if line.startswith("["):
# Stop at next section
break
if "=" in line:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
value = _parse_toml_value(value)
node_selector[key] = value
if node_selector:
metadata["nodeSelector"] = node_selector

return metadata if metadata else None


Expand Down
20 changes: 18 additions & 2 deletions plugin/kubectl_marimo/k8s.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import sys
from typing import Any

import click


def apply_resource(resource: dict[str, Any], dry_run: bool = False) -> bool:
"""Apply a Kubernetes resource using kubectl.
Expand All @@ -27,12 +29,26 @@ def apply_resource(resource: dict[str, Any], dry_run: bool = False) -> bool:
text=True,
)
if result.returncode != 0:
print(f"Error: {result.stderr}", file=sys.stderr)
stderr = result.stderr
# Format CRD validation errors nicely
if "is invalid:" in stderr:
click.echo(
click.style("Invalid notebook configuration:", fg="red"),
err=True,
)
for line in stderr.split("\n"):
line = line.strip()
if "spec." in line or "metadata." in line:
click.echo(f" • {line}", err=True)
elif line and "Error from server" not in line:
click.echo(f" {line}", err=True)
else:
click.echo(click.style(f"Error: {stderr}", fg="red"), err=True)
return False
print(result.stdout, end="")
return True
except FileNotFoundError:
print("Error: kubectl not found in PATH", file=sys.stderr)
click.echo(click.style("Error: kubectl not found in PATH", fg="red"), err=True)
return False


Expand Down
95 changes: 67 additions & 28 deletions plugin/kubectl_marimo/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@
# Default SSH image, configurable via environment
SSH_IMAGE = os.environ.get("SSH_IMAGE", "linuxserver/openssh-server:latest")

# Fields that pass through directly from frontmatter to spec (no transformation)
PASSTHROUGH_SPEC_FIELDS = {
"image",
"port",
"resources",
"sidecars",
"source",
"podOverrides",
}

# Fields requiring special handling
SPECIAL_FIELDS = {"auth", "nodeSelector", "env", "mounts", "storage", "title"}

# PEP723 fields that are parsed but not used for K8s (ignored silently)
PEP723_IGNORED_FIELDS = {"dependencies", "requires-python"}

# All known frontmatter fields (for unknown field warnings)
KNOWN_FRONTMATTER_FIELDS = (
PASSTHROUGH_SPEC_FIELDS | SPECIAL_FIELDS | PEP723_IGNORED_FIELDS
)


def parse_mount_uri(uri: str) -> tuple[str, str]:
"""Parse mount URI into (scheme, path).
Expand Down Expand Up @@ -160,7 +181,9 @@ 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]], list[tuple[str, str]]]:
) -> tuple[
dict[str, Any], list[tuple[str, str, str]], list[tuple[str, str]], list[str]
]:
"""Build MarimoNotebook custom resource.

Args:
Expand All @@ -172,41 +195,56 @@ def build_marimo_notebook(
source: Data source URI (rsync://, sshfs://, cw://)

Returns:
(resource, rsync_mounts, sshfs_mounts)
(resource, rsync_mounts, sshfs_mounts, warnings)
- resource: CRD dict to apply to cluster
- rsync_mounts: list of (source_path, mount_point, scheme) for kubectl cp
- sshfs_mounts: list of (remote_path, local_mount) for local sshfs
- warnings: list of warning messages (e.g., unknown frontmatter fields)
"""
warnings: list[str] = []
spec: dict[str, Any] = {
"mode": mode,
}

# Content (file-based deployments, empty string for directory mode)
spec["content"] = content if content else ""

# Default storage (PVC by notebook name) - always create PVC
storage_size = "1Gi"
if frontmatter and "storage" in frontmatter:
storage_size = frontmatter["storage"]
spec["storage"] = {"size": storage_size}

# Apply frontmatter settings
if frontmatter:
if "image" in frontmatter:
spec["image"] = frontmatter["image"]
if "port" in frontmatter:
spec["port"] = int(frontmatter["port"])
if "auth" in frontmatter:
if frontmatter["auth"] == "none":
spec["auth"] = {} # Empty auth block = --no-token

# Environment variables
# Warn about unknown fields (catches typos like "imge")
for field in frontmatter:
if field not in KNOWN_FRONTMATTER_FIELDS:
warnings.append(f"Unknown frontmatter field '{field}' will be ignored")

# Pass through valid spec fields directly (no transformation needed)
for field in PASSTHROUGH_SPEC_FIELDS:
if field in frontmatter:
spec[field] = frontmatter[field]

# Storage: handle both string shorthand and full object
storage = frontmatter.get("storage", "1Gi")
if isinstance(storage, str):
spec["storage"] = {"size": storage}
else:
spec["storage"] = storage

# Auth: "none" → empty dict for --no-token
if frontmatter.get("auth") == "none":
spec["auth"] = {}
elif "auth" in frontmatter:
spec["auth"] = frontmatter["auth"]

# nodeSelector → nested in podOverrides (merge with existing if present)
if "nodeSelector" in frontmatter:
spec.setdefault("podOverrides", {})["nodeSelector"] = frontmatter[
"nodeSelector"
]

# Env: parse simplified syntax to K8s EnvVar format
if "env" in frontmatter:
spec["env"] = parse_env(frontmatter["env"])

# Resources (CPU, memory, GPU)
if "resources" in frontmatter:
spec["resources"] = frontmatter["resources"]
else:
# Default storage when no frontmatter
spec["storage"] = {"size": "1Gi"}

# Collect mounts from --source and frontmatter
all_mounts = []
Expand All @@ -223,12 +261,13 @@ def build_marimo_notebook(
if cw_mounts:
spec["mounts"] = cw_mounts

# Add SSH sidecars for sshfs mounts
sidecars = []
# Add SSH sidecars for sshfs mounts (merge with existing sidecars if present)
ssh_sidecars = []
for i, _ in enumerate(sshfs_mounts):
sidecars.append(build_ssh_sidecar(i))
if sidecars:
spec["sidecars"] = sidecars
ssh_sidecars.append(build_ssh_sidecar(i))
if ssh_sidecars:
existing_sidecars = spec.get("sidecars", [])
spec["sidecars"] = existing_sidecars + ssh_sidecars

metadata = {"name": name}
if namespace is not None:
Expand All @@ -240,7 +279,7 @@ def build_marimo_notebook(
"metadata": metadata,
"spec": spec,
}
return resource, rsync_mounts, sshfs_mounts
return resource, rsync_mounts, sshfs_mounts, warnings


def to_yaml(resource: dict[str, Any]) -> str:
Expand Down
5 changes: 5 additions & 0 deletions plugin/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,8 @@ packages = ["kubectl_marimo"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]

[dependency-groups]
dev = [
"ruff>=0.15.8",
]
19 changes: 19 additions & 0 deletions plugin/tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,25 @@ def test_k8s_mounts_list(self):
assert meta["storage"] == "1Gi"
assert meta["mounts"] == ["sshfs://user@host:/data", "cw://bucket/prefix"]

def test_k8s_node_selector(self):
content = """# /// script
# dependencies = ["marimo"]
# ///
# [tool.marimo.k8s]
# storage = "1Gi"
#
# [tool.marimo.k8s.nodeSelector]
# compute.coreweave.com/node-pool = "gpu-node-pool"
# gpu = "true"
import marimo"""
meta = extract_pep723_metadata(content)
assert meta["storage"] == "1Gi"
assert "nodeSelector" in meta
assert (
meta["nodeSelector"]["compute.coreweave.com/node-pool"] == "gpu-node-pool"
)
assert meta["nodeSelector"]["gpu"] == "true"


class TestIsMaimoPython:
def test_import_marimo(self):
Expand Down
Loading
Loading