Skip to content

Commit 8058e69

Browse files
Update dependent resources (#8)
This takes a first stab at reconciling changes to `MarimoNotebook`: - pvc: explicitly declare storage attribute immutable (i.e. no reconciling) - service: attempt in-place change of service - recreate Marimo pod on changes Fixes #6 . Also, debug logging of desired state for pods. --------- Co-authored-by: dmadisetti <dmadisetti@coreweave.com>
1 parent 022116c commit 8058e69

6 files changed

Lines changed: 177 additions & 23 deletions

File tree

api/v1alpha1/marimonotebook_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ type MarimoNotebookSpec struct {
106106

107107
// Storage configures persistent storage for notebooks
108108
// +optional
109+
// +kubebuilder:validation:XValidation:rule="oldSelf == null || self == oldSelf",message="storage is immutable once set"
109110
Storage *StorageSpec `json:"storage,omitempty"`
110111

111112
// Resources for the marimo container

config/crd/bases/marimo.io_marimos.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,9 @@ spec:
767767
if empty)
768768
type: string
769769
type: object
770+
x-kubernetes-validations:
771+
- message: storage is immutable once set
772+
rule: oldSelf == null || self == oldSelf
770773
type: object
771774
x-kubernetes-validations:
772775
- message: storage is required when sidecars are specified

deploy/install.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,9 @@ spec:
775775
if empty)
776776
type: string
777777
type: object
778+
x-kubernetes-validations:
779+
- message: storage is immutable once set
780+
rule: oldSelf == null || self == oldSelf
778781
type: object
779782
x-kubernetes-validations:
780783
- message: storage is required when sidecars are specified

internal/controller/marimonotebook_controller.go

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
corev1 "k8s.io/api/core/v1"
2525
k8serrors "k8s.io/apimachinery/pkg/api/errors"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2627
"k8s.io/apimachinery/pkg/runtime"
2728
ctrl "sigs.k8s.io/controller-runtime"
2829
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -192,9 +193,19 @@ func (r *MarimoNotebookReconciler) reconcilePod(ctx context.Context, notebook *m
192193
return nil, err
193194
}
194195

196+
// Compute hash of desired pod spec for change detection
197+
specHash, err := resources.PodSpecHash(desired)
198+
if err != nil {
199+
return nil, fmt.Errorf("computing pod spec hash: %w", err)
200+
}
201+
if desired.Annotations == nil {
202+
desired.Annotations = make(map[string]string)
203+
}
204+
desired.Annotations[resources.PodSpecHashAnnotation] = specHash
205+
195206
// Check if Pod exists
196207
existing := &corev1.Pod{}
197-
err := r.Get(ctx, client.ObjectKeyFromObject(desired), existing)
208+
err = r.Get(ctx, client.ObjectKeyFromObject(desired), existing)
198209
if err != nil {
199210
if k8serrors.IsNotFound(err) {
200211
logger.Info("Creating Pod", "name", desired.Name)
@@ -213,41 +224,49 @@ func (r *MarimoNotebookReconciler) reconcilePod(ctx context.Context, notebook *m
213224
return nil, err
214225
}
215226

216-
// Pod exists - we don't update running pods (recreate strategy)
227+
// Pod exists - check if spec has changed and recreate if so
228+
if existingHash := existing.Annotations[resources.PodSpecHashAnnotation]; existingHash != specHash {
229+
logger.Info("Pod spec changed, recreating", "name", existing.Name)
230+
if err := r.Delete(ctx, existing); err != nil && !k8serrors.IsNotFound(err) {
231+
return nil, err
232+
}
233+
// Pod deleted - next reconcile will create the updated pod
234+
return nil, nil
235+
}
236+
217237
return existing, nil
218238
}
219239

220240
func (r *MarimoNotebookReconciler) reconcileService(ctx context.Context, notebook *marimov1alpha1.MarimoNotebook) (*corev1.Service, error) {
221241
logger := logf.FromContext(ctx)
222242
desired := resources.BuildService(notebook)
223243

224-
// Set owner reference
225-
if err := controllerutil.SetControllerReference(notebook, desired, r.Scheme); err != nil {
226-
return nil, err
244+
svc := &corev1.Service{
245+
ObjectMeta: metav1.ObjectMeta{
246+
Name: desired.Name,
247+
Namespace: desired.Namespace,
248+
},
227249
}
228250

229-
// Check if Service exists
230-
existing := &corev1.Service{}
231-
err := r.Get(ctx, client.ObjectKeyFromObject(desired), existing)
232-
if err != nil {
233-
if k8serrors.IsNotFound(err) {
234-
logger.Info("Creating Service", "name", desired.Name)
235-
if err := r.Create(ctx, desired); err != nil {
236-
if k8serrors.IsAlreadyExists(err) {
237-
// Service was created between Get and Create, re-fetch
238-
if err := r.Get(ctx, client.ObjectKeyFromObject(desired), existing); err != nil {
239-
return nil, err
240-
}
241-
return existing, nil
242-
}
243-
return nil, err
244-
}
245-
return desired, nil
251+
op, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error {
252+
if err := controllerutil.SetControllerReference(notebook, svc, r.Scheme); err != nil {
253+
return err
246254
}
255+
svc.Labels = desired.Labels
256+
svc.Spec.Ports = desired.Spec.Ports
257+
svc.Spec.Selector = desired.Spec.Selector
258+
svc.Spec.Type = desired.Spec.Type
259+
return nil
260+
})
261+
if err != nil {
247262
return nil, err
248263
}
249264

250-
return existing, nil
265+
if op != controllerutil.OperationResultNone {
266+
logger.Info("Reconciled Service", "name", svc.Name, "operation", op)
267+
}
268+
269+
return svc, nil
251270
}
252271

253272
func (r *MarimoNotebookReconciler) updateStatus(ctx context.Context, notebook *marimov1alpha1.MarimoNotebook, pod *corev1.Pod, svc *corev1.Service) (ctrl.Result, error) {

internal/controller/marimonotebook_controller_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,120 @@ var _ = Describe("MarimoNotebook Controller", func() {
370370
})
371371
})
372372

373+
Context("When updating a MarimoNotebook", func() {
374+
var notebook *marimov1alpha1.MarimoNotebook
375+
var namespacedName types.NamespacedName
376+
377+
BeforeEach(func() {
378+
notebook = &marimov1alpha1.MarimoNotebook{
379+
ObjectMeta: metav1.ObjectMeta{
380+
Name: "test-update-" + randString(),
381+
Namespace: "default",
382+
},
383+
Spec: marimov1alpha1.MarimoNotebookSpec{
384+
Source: "https://github.com/marimo-team/marimo.git",
385+
Storage: &marimov1alpha1.StorageSpec{Size: "1Gi"},
386+
},
387+
}
388+
namespacedName = types.NamespacedName{
389+
Name: notebook.Name,
390+
Namespace: notebook.Namespace,
391+
}
392+
})
393+
394+
AfterEach(func() {
395+
_ = k8sClient.Delete(ctx, notebook)
396+
})
397+
398+
It("should update Service ports when a sidecar with a port is added", func() {
399+
pausePort := int32(9090)
400+
401+
By("creating the MarimoNotebook with storage")
402+
Expect(k8sClient.Create(ctx, notebook)).To(Succeed())
403+
404+
By("waiting for Service to be created with only the main port")
405+
svc := &corev1.Service{}
406+
Eventually(func() error {
407+
return k8sClient.Get(ctx, namespacedName, svc)
408+
}, timeout, interval).Should(Succeed())
409+
Expect(svc.Spec.Ports).To(HaveLen(1))
410+
411+
By("updating the MarimoNotebook to add a pause sidecar with a container port")
412+
nb := &marimov1alpha1.MarimoNotebook{}
413+
Expect(k8sClient.Get(ctx, namespacedName, nb)).To(Succeed())
414+
nb.Spec.Sidecars = []marimov1alpha1.SidecarSpec{
415+
{
416+
Name: "pause",
417+
Image: "registry.k8s.io/pause:3.9",
418+
ExposePort: &pausePort,
419+
},
420+
}
421+
Expect(k8sClient.Update(ctx, nb)).To(Succeed())
422+
423+
By("checking Service is updated to include the sidecar port")
424+
Eventually(func() bool {
425+
if err := k8sClient.Get(ctx, namespacedName, svc); err != nil {
426+
return false
427+
}
428+
for _, port := range svc.Spec.Ports {
429+
if port.Name == "pause" && port.Port == pausePort {
430+
return true
431+
}
432+
}
433+
return false
434+
}, timeout, interval).Should(BeTrue(), "Service should expose the pause sidecar port")
435+
436+
Expect(svc.Spec.Ports).To(HaveLen(2))
437+
})
438+
439+
It("should recreate Pod when env vars are changed", func() {
440+
By("creating the MarimoNotebook")
441+
Expect(k8sClient.Create(ctx, notebook)).To(Succeed())
442+
443+
By("waiting for initial Pod to be created")
444+
pod := &corev1.Pod{}
445+
Eventually(func() error {
446+
return k8sClient.Get(ctx, namespacedName, pod)
447+
}, timeout, interval).Should(Succeed())
448+
originalUID := pod.UID
449+
450+
By("updating the notebook to add an env var")
451+
nb := &marimov1alpha1.MarimoNotebook{}
452+
Expect(k8sClient.Get(ctx, namespacedName, nb)).To(Succeed())
453+
nb.Spec.Env = []corev1.EnvVar{
454+
{Name: "MY_VAR", Value: "hello"},
455+
}
456+
Expect(k8sClient.Update(ctx, nb)).To(Succeed())
457+
458+
By("verifying Pod is recreated with the new env var")
459+
Eventually(func() bool {
460+
if err := k8sClient.Get(ctx, namespacedName, pod); err != nil {
461+
return false
462+
}
463+
if pod.UID == originalUID {
464+
return false // still the old pod
465+
}
466+
for _, env := range pod.Spec.Containers[0].Env {
467+
if env.Name == "MY_VAR" && env.Value == "hello" {
468+
return true
469+
}
470+
}
471+
return false
472+
}, timeout, interval).Should(BeTrue(), "Pod should be recreated with MY_VAR env var")
473+
})
474+
475+
It("should reject changes to the storage attribute", func() {
476+
By("creating the MarimoNotebook with storage")
477+
Expect(k8sClient.Create(ctx, notebook)).To(Succeed())
478+
479+
By("attempting to update the storage attribute")
480+
nb := &marimov1alpha1.MarimoNotebook{}
481+
Expect(k8sClient.Get(ctx, namespacedName, nb)).To(Succeed())
482+
nb.Spec.Storage = &marimov1alpha1.StorageSpec{Size: "2Gi"}
483+
Expect(k8sClient.Update(ctx, nb)).To(MatchError(ContainSubstring("storage is immutable once set")))
484+
})
485+
})
486+
373487
Context("When deleting a MarimoNotebook", func() {
374488
It("should clean up owned resources via garbage collection", func() {
375489
notebook := &marimov1alpha1.MarimoNotebook{

pkg/resources/pod.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package resources
22

33
import (
4+
"crypto/sha256"
5+
"encoding/hex"
46
"encoding/json"
57
"fmt"
68
"strings"
@@ -18,8 +20,20 @@ const (
1820
NotebookDir = "/home/marimo/notebooks"
1921
// DefaultMode is the default mode for running marimo.
2022
DefaultMode = "edit"
23+
// PodSpecHashAnnotation is the annotation key used to store the hash of the desired pod spec.
24+
PodSpecHashAnnotation = "marimo.io/pod-spec-hash"
2125
)
2226

27+
// PodSpecHash returns a SHA-256 hash of the pod's spec for change detection.
28+
func PodSpecHash(pod *corev1.Pod) (string, error) {
29+
data, err := json.Marshal(pod.Spec)
30+
if err != nil {
31+
return "", err
32+
}
33+
sum := sha256.Sum256(data)
34+
return hex.EncodeToString(sum[:]), nil
35+
}
36+
2337
// BuildPod creates a Pod spec for a MarimoNotebook.
2438
// Supports two content modes:
2539
// - source: clone from git URL

0 commit comments

Comments
 (0)