diff --git a/api/v1alpha1/marimonotebook_types.go b/api/v1alpha1/marimonotebook_types.go index 1b6fbf1..9762bae 100644 --- a/api/v1alpha1/marimonotebook_types.go +++ b/api/v1alpha1/marimonotebook_types.go @@ -123,6 +123,13 @@ type MarimoNotebookSpec struct { // +optional Mode string `json:"mode,omitempty"` + // BaseURL serves marimo under a path prefix (e.g. "/my-notebook"), passed + // as --base-url. Set it when a reverse proxy routes a sub-path of a shared + // host to this notebook, so asset and websocket URLs include the prefix. + // +kubebuilder:validation:Pattern=`^/.*[^/]$` + // +optional + BaseURL string `json:"baseUrl,omitempty"` + // Env variables for the marimo container // +optional Env []corev1.EnvVar `json:"env,omitempty"` diff --git a/config/crd/bases/marimo.io_marimos.yaml b/config/crd/bases/marimo.io_marimos.yaml index 1ab8ba0..9f04966 100644 --- a/config/crd/bases/marimo.io_marimos.yaml +++ b/config/crd/bases/marimo.io_marimos.yaml @@ -88,6 +88,13 @@ spec: - secretKeyRef type: object type: object + baseUrl: + description: |- + BaseURL serves marimo under a path prefix (e.g. "/my-notebook"), passed + as --base-url. Set it when a reverse proxy routes a sub-path of a shared + host to this notebook, so asset and websocket URLs include the prefix. + pattern: ^/.*[^/]$ + type: string content: description: |- Content is inline notebook content (marimo .py or .md format) diff --git a/deploy/charts/marimo-operator/crds/marimo.io_marimos.yaml b/deploy/charts/marimo-operator/crds/marimo.io_marimos.yaml index 1ab8ba0..9f04966 100644 --- a/deploy/charts/marimo-operator/crds/marimo.io_marimos.yaml +++ b/deploy/charts/marimo-operator/crds/marimo.io_marimos.yaml @@ -88,6 +88,13 @@ spec: - secretKeyRef type: object type: object + baseUrl: + description: |- + BaseURL serves marimo under a path prefix (e.g. "/my-notebook"), passed + as --base-url. Set it when a reverse proxy routes a sub-path of a shared + host to this notebook, so asset and websocket URLs include the prefix. + pattern: ^/.*[^/]$ + type: string content: description: |- Content is inline notebook content (marimo .py or .md format) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c18b775..52496f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,7 @@ The marimo-operator is a Kubernetes operator that manages `MarimoNotebook` custo | `storage` | StorageSpec | No | PVC configuration | | `resources` | ResourcesSpec | No | CPU/memory/GPU requests and limits | | `auth` | AuthSpec | No | Authentication configuration | +| `baseUrl` | string | No | Path prefix to serve marimo under (`--base-url`), e.g. `/my-notebook` | | `sidecars` | []SidecarSpec | No | Additional containers | | `podOverrides` | PodSpec | No | Strategic merge patch for Pod customization | diff --git a/internal/controller/marimonotebook_controller_test.go b/internal/controller/marimonotebook_controller_test.go index 17412a3..83636be 100644 --- a/internal/controller/marimonotebook_controller_test.go +++ b/internal/controller/marimonotebook_controller_test.go @@ -184,6 +184,31 @@ var _ = Describe("MarimoNotebook Controller", func() { Expect(pod.Spec.Containers[0].Image).To(Equal("marimo:custom")) }) + + It("should pass baseUrl to marimo as --base-url", func() { + notebook.Spec.BaseURL = "/apps/demo" + + By("creating the MarimoNotebook with a base URL") + Expect(k8sClient.Create(ctx, notebook)).To(Succeed()) + + By("checking Pod args include --base-url") + pod := &corev1.Pod{} + Eventually(func() error { + return k8sClient.Get(ctx, namespacedName, pod) + }, timeout, interval).Should(Succeed()) + + Expect(pod.Spec.Containers[0].Args).To(ContainElement("--base-url=/apps/demo")) + }) + + DescribeTable("should reject a baseUrl marimo would refuse", + func(baseURL string) { + notebook.Spec.BaseURL = baseURL + Expect(k8sClient.Create(ctx, notebook)).To(MatchError(ContainSubstring("spec.baseUrl"))) + }, + Entry("bare slash", "/"), + Entry("no leading slash", "apps/demo"), + Entry("trailing slash", "/apps/demo/"), + ) }) Context("When creating a MarimoNotebook with storage", func() { diff --git a/pkg/resources/pod.go b/pkg/resources/pod.go index e79eba5..1bc64de 100644 --- a/pkg/resources/pod.go +++ b/pkg/resources/pod.go @@ -158,6 +158,9 @@ func BuildPod(notebook *marimov1alpha1.MarimoNotebook) *corev1.Pod { "--host=0.0.0.0", fmt.Sprintf("--port=%d", notebook.Spec.Port), } + if notebook.Spec.BaseURL != "" { + marimoArgs = append(marimoArgs, "--base-url="+notebook.Spec.BaseURL) + } // Auth configuration: // - auth nil: auto-generate token (secure by default, plugin fetches from logs) diff --git a/pkg/resources/pod_test.go b/pkg/resources/pod_test.go index fa4179a..7a7ecb9 100644 --- a/pkg/resources/pod_test.go +++ b/pkg/resources/pod_test.go @@ -984,6 +984,64 @@ func TestBuildPod_ModeEdit(t *testing.T) { } } +func TestBuildPod_BaseURL(t *testing.T) { + content := "import marimo\napp = marimo.App()\n" + notebook := &marimov1alpha1.MarimoNotebook{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-notebook", + Namespace: "default", + }, + Spec: marimov1alpha1.MarimoNotebookSpec{ + Image: "ghcr.io/marimo-team/marimo:latest", + Port: 2718, + Content: &content, + Mode: "run", + BaseURL: "/apps/test-notebook", + }, + } + + pod := BuildPod(notebook) + args := pod.Spec.Containers[0].Args + + baseURLIdx, sandboxIdx := -1, -1 + for i, arg := range args { + switch arg { + case "--base-url=/apps/test-notebook": + baseURLIdx = i + case "--sandbox": + sandboxIdx = i + } + } + if baseURLIdx == -1 { + t.Fatalf("expected --base-url=/apps/test-notebook in args, got %v", args) + } + // The notebook path must stay last, so flags have to precede --sandbox . + if sandboxIdx == -1 || baseURLIdx > sandboxIdx { + t.Errorf("expected --base-url before --sandbox, got %v", args) + } +} + +func TestBuildPod_BaseURLUnset(t *testing.T) { + notebook := &marimov1alpha1.MarimoNotebook{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-notebook", + Namespace: "default", + }, + Spec: marimov1alpha1.MarimoNotebookSpec{ + Image: "ghcr.io/marimo-team/marimo:latest", + Port: 2718, + Source: "https://github.com/marimo-team/marimo.git", + }, + } + + pod := BuildPod(notebook) + for _, arg := range pod.Spec.Containers[0].Args { + if strings.HasPrefix(arg, "--base-url") { + t.Errorf("expected no --base-url flag when baseUrl is unset, got %q", arg) + } + } +} + func TestBuildPod_EnvVars(t *testing.T) { notebook := &marimov1alpha1.MarimoNotebook{ ObjectMeta: metav1.ObjectMeta{