diff --git a/internal/controller/marimonotebook_controller_test.go b/internal/controller/marimonotebook_controller_test.go index 17412a3..d6580a9 100644 --- a/internal/controller/marimonotebook_controller_test.go +++ b/internal/controller/marimonotebook_controller_test.go @@ -472,6 +472,39 @@ var _ = Describe("MarimoNotebook Controller", func() { }, timeout, interval).Should(BeTrue(), "Pod should be recreated with MY_VAR env var") }) + It("should recreate Pod when inline content is changed", func() { + original := "import marimo\napp = marimo.App()\n" + notebook.Spec.Source = "" + notebook.Spec.Content = &original + + By("creating the MarimoNotebook with inline content") + Expect(k8sClient.Create(ctx, notebook)).To(Succeed()) + + By("waiting for initial Pod to be created") + pod := &corev1.Pod{} + Eventually(func() error { + return k8sClient.Get(ctx, namespacedName, pod) + }, timeout, interval).Should(Succeed()) + originalUID := pod.UID + + By("updating the notebook content") + updated := original + "\n@app.cell\ndef _():\n return\n" + nb := &marimov1alpha1.MarimoNotebook{} + Expect(k8sClient.Get(ctx, namespacedName, nb)).To(Succeed()) + nb.Spec.Content = &updated + Expect(k8sClient.Update(ctx, nb)).To(Succeed()) + + // The copy-content init container only runs at pod start, so the + // running pod keeps serving the old file until it is replaced. + By("verifying Pod is recreated") + Eventually(func() bool { + if err := k8sClient.Get(ctx, namespacedName, pod); err != nil { + return false + } + return pod.UID != originalUID + }, timeout, interval).Should(BeTrue(), "Pod should be recreated after a content change") + }) + It("should reject changes to the storage attribute", func() { By("creating the MarimoNotebook with storage") Expect(k8sClient.Create(ctx, notebook)).To(Succeed()) diff --git a/pkg/resources/pod.go b/pkg/resources/pod.go index e79eba5..c93a845 100644 --- a/pkg/resources/pod.go +++ b/pkg/resources/pod.go @@ -22,14 +22,22 @@ const ( DefaultMode = "edit" // PodSpecHashAnnotation is the annotation key used to store the hash of the desired pod spec. PodSpecHashAnnotation = "marimo.io/pod-spec-hash" + // ContentHashAnnotation records the hash of inline content on content-mode pods. + ContentHashAnnotation = "marimo.io/content-hash" ) // PodSpecHash returns a SHA-256 hash of the pod's spec for change detection. +// The content hash annotation is folded in because the spec only names the +// content ConfigMap: a content change leaves the spec untouched, and the +// copy-content init container has already copied the old file. func PodSpecHash(pod *corev1.Pod) (string, error) { data, err := json.Marshal(pod.Spec) if err != nil { return "", err } + if contentHash, ok := pod.Annotations[ContentHashAnnotation]; ok { + data = append(data, contentHash...) + } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]), nil } @@ -299,7 +307,7 @@ func BuildPod(notebook *marimov1alpha1.MarimoNotebook) *corev1.Pod { basePodSpec = applyPodOverrides(basePodSpec, *notebook.Spec.PodOverrides) } - return &corev1.Pod{ + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: notebook.Name, Namespace: notebook.Namespace, @@ -307,6 +315,12 @@ func BuildPod(notebook *marimov1alpha1.MarimoNotebook) *corev1.Pod { }, Spec: basePodSpec, } + if contentKey != "" { + pod.Annotations = map[string]string{ + ContentHashAnnotation: ContentHash(*notebook.Spec.Content), + } + } + return pod } // buildSidecarContainer creates a container spec from a SidecarSpec. diff --git a/pkg/resources/pod_test.go b/pkg/resources/pod_test.go index fa4179a..2b6c70f 100644 --- a/pkg/resources/pod_test.go +++ b/pkg/resources/pod_test.go @@ -1,6 +1,10 @@ package resources import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "reflect" "strings" "testing" @@ -1510,3 +1514,68 @@ func TestBuildPod_NoSSHFSSidecar_NoSecretMount(t *testing.T) { } } } + +func TestPodSpecHash_ChangesWithContent(t *testing.T) { + build := func(content string) *corev1.Pod { + return BuildPod(&marimov1alpha1.MarimoNotebook{ + ObjectMeta: metav1.ObjectMeta{Name: "test-notebook", Namespace: "default"}, + Spec: marimov1alpha1.MarimoNotebookSpec{ + Image: "ghcr.io/marimo-team/marimo:latest", + Port: 2718, + Content: &content, + }, + }) + } + + before, after := build("import marimo\n"), build("import marimo\nimport polars\n") + + // Both pods only reference the ConfigMap by name, so the specs are identical. + if !reflect.DeepEqual(before.Spec, after.Spec) { + t.Fatal("expected identical pod specs for different content") + } + if before.Annotations[ContentHashAnnotation] == after.Annotations[ContentHashAnnotation] { + t.Error("expected content hash annotation to differ") + } + + beforeHash, err := PodSpecHash(before) + if err != nil { + t.Fatal(err) + } + afterHash, err := PodSpecHash(after) + if err != nil { + t.Fatal(err) + } + if beforeHash == afterHash { + t.Error("expected pod spec hash to change when content changes") + } +} + +func TestPodSpecHash_SourceModeUnchanged(t *testing.T) { + pod := BuildPod(&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", + }, + }) + + if _, ok := pod.Annotations[ContentHashAnnotation]; ok { + t.Error("expected no content hash annotation in source mode") + } + + // Source-mode hashes must match the spec-only hash so that upgrading the + // operator does not recreate every existing pod. + data, err := json.Marshal(pod.Spec) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + got, err := PodSpecHash(pod) + if err != nil { + t.Fatal(err) + } + if want := hex.EncodeToString(sum[:]); got != want { + t.Errorf("expected spec-only hash %s, got %s", want, got) + } +}