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
6 changes: 0 additions & 6 deletions docs/2026-02-05-shared-mount-syncer.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,6 @@ Alternative (Kubernetes-native):
For Spritz pods, the shared mount is writable. The syncer sidecar uses a filesystem
watcher to publish quickly when content changes, with a periodic safety tick.

## Deprecated: Shared Config PVC

The shared config PVC approach is sunsetted and is **not** planned for implementation
in the near future. Any existing PVC code should be treated as legacy and not enabled
for new deployments. Use the object-storage syncer above.

## GCS Uniform Bucket-Level Access (Important)

When using GCS buckets with Uniform Bucket-Level Access (UBLA) enabled, rclone must
Expand Down
14 changes: 4 additions & 10 deletions docs/2026-02-24-simplest-spritz-deployment-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ The default installation should require only:
- `global.ingress.className`: ingress class
- `global.ingress.tls.enabled`: whether TLS is enabled
- `global.ingress.tls.secretName` (optional): pre-provisioned TLS secret name
- `operator.homePVC.storageClass` (optional): home PVC storage class override

Everything else should have working defaults.

Expand Down Expand Up @@ -98,10 +97,6 @@ ui:
apiBaseUrl: /api

operator:
homePVC:
enabled: true
storageClassName: standard

sharedMounts:
enabled: false

Expand Down Expand Up @@ -150,7 +145,6 @@ File: `helm/spritz/values.yaml`
- Add `global.ingress.tls.secretName` (default empty; operator-provided).
- Keep `ui.ingress.enabled` default `true` for single-host installs.
- Keep `ui.apiBaseUrl` default `/api`.
- Keep `operator.homePVC.enabled` default `true`.
- Keep `operator.sharedMounts.enabled` and `api.sharedMounts.enabled` default `false`.
- Remove compatibility-only keys from the default path:
- `ui.ingress.host`
Expand Down Expand Up @@ -224,15 +218,15 @@ Required behavior:

## Storage and Sync Defaults

- Default mode is per-devbox persistent home PVC.
- Default mode is ephemeral home storage (`EmptyDir` at `/home/dev`).
- Shared cross-devbox live sync is disabled by default.
- Shared mounts remain available as an opt-in advanced feature.

Rationale:

- PVC-only mode has fewer failure modes.
- This is enough for most single-devbox usage.
- Operators can enable shared sync only when they need it.
- Ephemeral defaults keep install and cleanup behavior predictable.
- This is enough for stateless single-devbox usage.
- Operators can enable shared sync only for specific paths they need to persist.

## Optional Advanced Mode

Expand Down
38 changes: 6 additions & 32 deletions helm/spritz/templates/operator-deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
{{- if hasKey .Values.operator "homePVC" -}}
{{ fail "operator.homePVC has been removed; use operator.homeSizeLimit and sharedMounts instead" }}
{{- end -}}
{{- if hasKey .Values.operator "sharedConfigPVC" -}}
{{ fail "operator.sharedConfigPVC has been removed; use operator.sharedMounts/api.sharedMounts instead" }}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -43,38 +49,6 @@ spec:
- name: SPRITZ_HOME_SIZE_LIMIT
value: {{ .Values.operator.homeSizeLimit | quote }}
{{- end }}
{{- if and .Values.operator.homePVC .Values.operator.homePVC.enabled }}
- name: SPRITZ_HOME_PVC_PREFIX
value: {{ .Values.operator.homePVC.prefix | quote }}
- name: SPRITZ_HOME_PVC_SIZE
value: {{ .Values.operator.homePVC.size | quote }}
- name: SPRITZ_HOME_PVC_ACCESS_MODES
value: {{ join "," .Values.operator.homePVC.accessModes | quote }}
{{- if .Values.operator.homePVC.storageClass }}
- name: SPRITZ_HOME_PVC_STORAGE_CLASS
value: {{ .Values.operator.homePVC.storageClass | quote }}
{{- end }}
{{- if .Values.operator.homePVC.mountPaths }}
- name: SPRITZ_HOME_MOUNT_PATHS
value: {{ join "," .Values.operator.homePVC.mountPaths | quote }}
{{- end }}
{{- end }}
{{- if and .Values.operator.sharedConfigPVC .Values.operator.sharedConfigPVC.enabled }}
- name: SPRITZ_SHARED_CONFIG_PVC_PREFIX
value: {{ .Values.operator.sharedConfigPVC.prefix | quote }}
- name: SPRITZ_SHARED_CONFIG_PVC_SIZE
value: {{ .Values.operator.sharedConfigPVC.size | quote }}
- name: SPRITZ_SHARED_CONFIG_PVC_ACCESS_MODES
value: {{ join "," .Values.operator.sharedConfigPVC.accessModes | quote }}
{{- if .Values.operator.sharedConfigPVC.storageClass }}
- name: SPRITZ_SHARED_CONFIG_PVC_STORAGE_CLASS
value: {{ .Values.operator.sharedConfigPVC.storageClass | quote }}
{{- end }}
{{- if .Values.operator.sharedConfigPVC.mountPath }}
- name: SPRITZ_SHARED_CONFIG_MOUNT_PATH
value: {{ .Values.operator.sharedConfigPVC.mountPath | quote }}
{{- end }}
{{- end }}
{{- if and .Values.operator.sharedMounts .Values.operator.sharedMounts.enabled }}
- name: SPRITZ_SHARED_MOUNTS
value: {{ toJson .Values.operator.sharedMounts.mounts | quote }}
Expand Down
17 changes: 0 additions & 17 deletions helm/spritz/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,23 +77,6 @@ operator:
workspaceSizeLimit: 10Gi
homeSizeLimit: 5Gi
podNodeSelector: ""
homePVC:
enabled: true
prefix: spritz-home
size: 5Gi
accessModes:
- ReadWriteOnce
storageClass: ""
mountPaths:
- /home/dev
sharedConfigPVC:
enabled: false
prefix: spritz-shared-config
size: 100Mi
accessModes:
- ReadWriteMany
storageClass: ""
mountPath: /shared
sharedMounts:
enabled: false
mounts: []
Expand Down
143 changes: 143 additions & 0 deletions operator/controllers/home_mounts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package controllers

import (
"testing"

corev1 "k8s.io/api/core/v1"

spritzv1 "spritz.sh/operator/api/v1"
)

func TestParseCSV(t *testing.T) {
if parseCSV("") != nil {
t.Fatal("expected nil for empty CSV")
}
got := parseCSV("/home/dev, /home/spritz")
if len(got) != 2 {
t.Fatalf("expected 2 entries, got %d", len(got))
}
if got[0] != "/home/dev" || got[1] != "/home/spritz" {
t.Fatalf("unexpected values: %v", got)
}
}

func TestParseNodeSelector(t *testing.T) {
selector, err := parseNodeSelector("spritz.sh/storage-ready=true,zone=fsn1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if selector["spritz.sh/storage-ready"] != "true" || selector["zone"] != "fsn1" {
t.Fatalf("unexpected selector: %v", selector)
}

if _, err := parseNodeSelector("missingequals"); err == nil {
t.Fatal("expected error for invalid selector entry")
}
if _, err := parseNodeSelector("=novalue"); err == nil {
t.Fatal("expected error for empty key")
}
if _, err := parseNodeSelector("key="); err == nil {
t.Fatal("expected error for empty value")
}
}

func TestBuildHomeMountsDefault(t *testing.T) {
mounts := buildHomeMounts()
if len(mounts) != 1 {
t.Fatalf("expected 1 mount, got %d", len(mounts))
}
if mounts[0].Name != "home" {
t.Fatalf("unexpected mount name: %s", mounts[0].Name)
}
if mounts[0].MountPath != repoInitHomeDir {
t.Fatalf("unexpected mount path: %s", mounts[0].MountPath)
}
}

func TestBuildPodSecurityContext(t *testing.T) {
if ctx := buildPodSecurityContext(false, false); ctx != nil {
t.Fatal("expected nil security context when no shared mounts or repo init")
}

ctx := buildPodSecurityContext(true, false)
if ctx == nil || ctx.FSGroup == nil || *ctx.FSGroup != repoInitGroupID {
t.Fatalf("expected fsGroup %d when shared mounts enabled, got %+v", repoInitGroupID, ctx)
}

ctx = buildPodSecurityContext(false, true)
if ctx == nil || ctx.FSGroup == nil || *ctx.FSGroup != repoInitGroupID {
t.Fatalf("expected fsGroup %d when repo init present, got %+v", repoInitGroupID, ctx)
}
}

func TestBuildRepoInitContainerDedupesHomeMount(t *testing.T) {
spritz := &spritzv1.Spritz{
Spec: spritzv1.SpritzSpec{
Repo: &spritzv1.SpritzRepo{
URL: "https://github.com/example/repo.git",
},
},
}

homeMounts := buildHomeMounts()
repos := repoEntries(spritz)
containers, _, err := buildRepoInitContainers(spritz, repos, homeMounts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(containers) == 0 {
t.Fatal("expected repo init container")
}

count := 0
for _, mount := range containers[0].VolumeMounts {
if mount.MountPath == repoInitHomeDir {
count++
}
}
if count != 1 {
t.Fatalf("expected single %s mount, got %d", repoInitHomeDir, count)
}
}

func TestRepoDirNeedsWorkspaceMountHonorsSharedMounts(t *testing.T) {
mountRoots := []corev1.VolumeMount{
{Name: "shared", MountPath: "/shared"},
}
if repoDirNeedsWorkspaceMount("/shared/repo", mountRoots) {
t.Fatal("expected repo dir under shared mount to skip workspace mount")
}
if repoDirNeedsWorkspaceMount("/workspace/repo", mountRoots) {
t.Fatal("expected repo dir under /workspace to skip workspace mount")
}
}

func TestValidateRepoDir(t *testing.T) {
cases := []struct {
name string
dir string
wantErr bool
}{
{"empty ok", "", false},
{"relative ok", "spritz", false},
{"relative nested ok", "project/app", false},
{"relative up invalid", "../etc", true},
{"relative up nested invalid", "foo/../../etc", true},
{"absolute workspace ok", "/workspace/spritz", false},
{"absolute workspace nested ok", "/workspace/spritz/app", false},
{"absolute escape invalid", "/etc", true},
{"absolute escape via traversal invalid", "/workspace/../etc", true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateRepoDir(tc.dir)
if tc.wantErr && err == nil {
t.Fatalf("expected error for %s", tc.dir)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for %s: %v", tc.dir, err)
}
})
}
}
Loading