Skip to content
Open
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
33 changes: 33 additions & 0 deletions internal/controller/marimonotebook_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
16 changes: 15 additions & 1 deletion pkg/resources/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -299,14 +307,20 @@ 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,
Labels: Labels(notebook),
},
Spec: basePodSpec,
}
if contentKey != "" {
pod.Annotations = map[string]string{
ContentHashAnnotation: ContentHash(*notebook.Spec.Content),
}
}
return pod
}

// buildSidecarContainer creates a container spec from a SidecarSpec.
Expand Down
69 changes: 69 additions & 0 deletions pkg/resources/pod_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package resources

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}