Skip to content

Commit a677f7b

Browse files
stubbiclaude
andcommitted
feat(instance): optional brand theming via spec.branding.cssConfigMapRef
Add an optional Branding spec so a deployer can apply a runtime UI brand theme without rebuilding the product image. When spec.branding.cssConfigMapRef is set, the operator (mirroring the spec.objectStorage != nil gating pattern): - mounts the referenced ConfigMap read-only at /etc/paperclip/branding, and - sets PAPERCLIP_BRAND_DIR to that path. The product then serves the directory under /branding and loads /branding/brand.css after its bundled stylesheet, letting the ConfigMap's brand.css override the app's CSS variables. Unset = unchanged default (no volume, no mount, no env), so existing instances are untouched. Regenerated CRD/deepcopy via controller-gen and synced the Helm chart CRD. Adds branding wiring tests; make test green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3f1beac commit a677f7b

7 files changed

Lines changed: 220 additions & 1 deletion

File tree

api/v1alpha1/paperclipinstance_types.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ type InstanceSpec struct {
5353
// +optional
5454
ObjectStorage *ObjectStorageSpec `json:"objectStorage,omitempty"`
5555

56+
// Branding configures optional UI brand theming for the Paperclip product.
57+
// +optional
58+
Branding *BrandingSpec `json:"branding,omitempty"`
59+
5660
// Heartbeat configures the agent heartbeat scheduler.
5761
// +optional
5862
Heartbeat HeartbeatSpec `json:"heartbeat,omitempty"`
@@ -513,6 +517,20 @@ type ObjectStorageSpec struct {
513517
ForcePathStyle *bool `json:"forcePathStyle,omitempty"`
514518
}
515519

520+
// BrandingSpec configures optional UI brand theming. When set, the operator
521+
// mounts the referenced ConfigMap into the Paperclip container and points the
522+
// server at it via PAPERCLIP_BRAND_DIR. The server then serves the directory
523+
// under /branding and loads /branding/brand.css after the bundled stylesheet,
524+
// so the ConfigMap's brand.css can override the product's CSS variables without
525+
// rebuilding the image.
526+
type BrandingSpec struct {
527+
// CSSConfigMapRef references a ConfigMap whose keys are mounted as files in
528+
// the brand directory. Provide a "brand.css" key for the runtime brand
529+
// stylesheet; additional keys (e.g. fonts referenced by relative URL) are
530+
// mounted alongside it.
531+
CSSConfigMapRef *corev1.LocalObjectReference `json:"cssConfigMapRef,omitempty"`
532+
}
533+
516534
// HeartbeatSpec configures the agent heartbeat scheduler.
517535
type HeartbeatSpec struct {
518536
// Enabled controls whether the heartbeat scheduler runs. Defaults to true.

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,6 +1888,29 @@ spec:
18881888
Omit to use only app-native backups.
18891889
type: string
18901890
type: object
1891+
branding:
1892+
description: Branding configures optional UI brand theming for the
1893+
Paperclip product.
1894+
properties:
1895+
cssConfigMapRef:
1896+
description: |-
1897+
CSSConfigMapRef references a ConfigMap whose keys are mounted as files in
1898+
the brand directory. Provide a "brand.css" key for the runtime brand
1899+
stylesheet; additional keys (e.g. fonts referenced by relative URL) are
1900+
mounted alongside it.
1901+
properties:
1902+
name:
1903+
default: ""
1904+
description: |-
1905+
Name of the referent.
1906+
This field is effectively required, but due to backwards compatibility is
1907+
allowed to be empty. Instances of this type with an empty value here are
1908+
almost certainly wrong.
1909+
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
1910+
type: string
1911+
type: object
1912+
x-kubernetes-map-type: atomic
1913+
type: object
18911914
connections:
18921915
description: |-
18931916
Connections configures third-party OAuth provider credentials for

config/crd/bases/paperclip.inc_instances.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,6 +1882,29 @@ spec:
18821882
Omit to use only app-native backups.
18831883
type: string
18841884
type: object
1885+
branding:
1886+
description: Branding configures optional UI brand theming for the
1887+
Paperclip product.
1888+
properties:
1889+
cssConfigMapRef:
1890+
description: |-
1891+
CSSConfigMapRef references a ConfigMap whose keys are mounted as files in
1892+
the brand directory. Provide a "brand.css" key for the runtime brand
1893+
stylesheet; additional keys (e.g. fonts referenced by relative URL) are
1894+
mounted alongside it.
1895+
properties:
1896+
name:
1897+
default: ""
1898+
description: |-
1899+
Name of the referent.
1900+
This field is effectively required, but due to backwards compatibility is
1901+
allowed to be empty. Instances of this type with an empty value here are
1902+
almost certainly wrong.
1903+
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
1904+
type: string
1905+
type: object
1906+
x-kubernetes-map-type: atomic
1907+
type: object
18851908
connections:
18861909
description: |-
18871910
Connections configures third-party OAuth provider credentials for
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package resources
2+
3+
import (
4+
"testing"
5+
6+
corev1 "k8s.io/api/core/v1"
7+
8+
paperclipv1alpha1 "github.com/paperclipinc/paperclip-operator/api/v1alpha1"
9+
)
10+
11+
func findVolume(volumes []corev1.Volume, name string) *corev1.Volume {
12+
for i := range volumes {
13+
if volumes[i].Name == name {
14+
return &volumes[i]
15+
}
16+
}
17+
return nil
18+
}
19+
20+
func findMount(mounts []corev1.VolumeMount, name string) *corev1.VolumeMount {
21+
for i := range mounts {
22+
if mounts[i].Name == name {
23+
return &mounts[i]
24+
}
25+
}
26+
return nil
27+
}
28+
29+
func TestBuildStatefulSetNoBrandingByDefault(t *testing.T) {
30+
instance := newTestInstance("my-paperclip")
31+
sts := BuildStatefulSet(instance, nil)
32+
33+
if v := findVolume(sts.Spec.Template.Spec.Volumes, BrandVolumeName); v != nil {
34+
t.Errorf("expected no brand volume when branding unset, got %+v", v)
35+
}
36+
37+
container := sts.Spec.Template.Spec.Containers[0]
38+
if m := findMount(container.VolumeMounts, BrandVolumeName); m != nil {
39+
t.Errorf("expected no brand volume mount when branding unset, got %+v", m)
40+
}
41+
for _, env := range container.Env {
42+
if env.Name == EnvBrandDir {
43+
t.Errorf("expected no %s env when branding unset", EnvBrandDir)
44+
}
45+
}
46+
}
47+
48+
func TestBuildStatefulSetBrandingWiring(t *testing.T) {
49+
instance := newTestInstance("my-paperclip")
50+
instance.Spec.Branding = &paperclipv1alpha1.BrandingSpec{
51+
CSSConfigMapRef: &corev1.LocalObjectReference{Name: "paperclip-brand-css"},
52+
}
53+
sts := BuildStatefulSet(instance, nil)
54+
55+
vol := findVolume(sts.Spec.Template.Spec.Volumes, BrandVolumeName)
56+
if vol == nil {
57+
t.Fatalf("expected a %q volume", BrandVolumeName)
58+
}
59+
if vol.ConfigMap == nil {
60+
t.Fatalf("expected brand volume to be backed by a ConfigMap, got %+v", vol.VolumeSource)
61+
}
62+
if vol.ConfigMap.Name != "paperclip-brand-css" {
63+
t.Errorf("expected brand ConfigMap name paperclip-brand-css, got %q", vol.ConfigMap.Name)
64+
}
65+
66+
container := sts.Spec.Template.Spec.Containers[0]
67+
mount := findMount(container.VolumeMounts, BrandVolumeName)
68+
if mount == nil {
69+
t.Fatalf("expected a %q volume mount on the main container", BrandVolumeName)
70+
}
71+
if mount.MountPath != BrandMountPath {
72+
t.Errorf("expected brand mount path %q, got %q", BrandMountPath, mount.MountPath)
73+
}
74+
if !mount.ReadOnly {
75+
t.Error("expected the brand mount to be read-only")
76+
}
77+
78+
var brandDir string
79+
for _, env := range container.Env {
80+
if env.Name == EnvBrandDir {
81+
brandDir = env.Value
82+
}
83+
}
84+
if brandDir != BrandMountPath {
85+
t.Errorf("expected %s=%s, got %q", EnvBrandDir, BrandMountPath, brandDir)
86+
}
87+
}

internal/resources/common.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const (
3636
DataVolumeName = "paperclip-data"
3737
// DataMountPath is the mount path for the Paperclip data volume.
3838
DataMountPath = "/paperclip"
39+
// BrandVolumeName is the name of the optional brand-assets volume.
40+
BrandVolumeName = "paperclip-branding"
41+
// BrandMountPath is the read-only mount path for the brand-assets ConfigMap.
42+
// The server serves this directory under /branding (PAPERCLIP_BRAND_DIR).
43+
BrandMountPath = "/etc/paperclip/branding"
44+
// EnvBrandDir is the environment variable pointing the server at the brand dir.
45+
EnvBrandDir = "PAPERCLIP_BRAND_DIR"
3946
// DatabaseVolumeName is the name of the PostgreSQL data volume.
4047
DatabaseVolumeName = "pgdata"
4148
// DatabaseMountPath is the mount path for the PostgreSQL data volume.

internal/resources/statefulset.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,12 @@ func buildEnvVars(instance *paperclipv1alpha1.Instance) []corev1.EnvVar {
282282
}
283283
}
284284

285+
// Brand theming: point the server at the mounted brand directory so it
286+
// serves /branding/brand.css and loads it after the bundled stylesheet.
287+
if brandingConfigMapRef(instance) != nil {
288+
vars = append(vars, corev1.EnvVar{Name: EnvBrandDir, Value: BrandMountPath})
289+
}
290+
285291
// LLM API keys
286292
if instance.Spec.Adapters.APIKeysSecretRef != nil {
287293
vars = append(vars, corev1.EnvVar{
@@ -692,15 +698,45 @@ func buildVolumes(instance *paperclipv1alpha1.Instance) []corev1.Volume {
692698
})
693699
}
694700

701+
// Optional brand-assets volume: a ConfigMap of brand files (brand.css, ...)
702+
// mounted read-only and served by the app under /branding.
703+
if ref := brandingConfigMapRef(instance); ref != nil {
704+
volumes = append(volumes, corev1.Volume{
705+
Name: BrandVolumeName,
706+
VolumeSource: corev1.VolumeSource{
707+
ConfigMap: &corev1.ConfigMapVolumeSource{
708+
LocalObjectReference: *ref,
709+
},
710+
},
711+
})
712+
}
713+
695714
return volumes
696715
}
697716

717+
// brandingConfigMapRef returns the brand ConfigMap reference when branding is
718+
// configured, or nil. Centralised so the volume, mount, and env wiring stay
719+
// in sync (mirrors the spec.ObjectStorage != nil gating pattern).
720+
func brandingConfigMapRef(instance *paperclipv1alpha1.Instance) *corev1.LocalObjectReference {
721+
if instance.Spec.Branding == nil {
722+
return nil
723+
}
724+
return instance.Spec.Branding.CSSConfigMapRef
725+
}
726+
698727
func buildVolumeMounts(instance *paperclipv1alpha1.Instance) []corev1.VolumeMount {
699-
mounts := make([]corev1.VolumeMount, 0, 1+len(instance.Spec.ExtraVolumeMounts))
728+
mounts := make([]corev1.VolumeMount, 0, 2+len(instance.Spec.ExtraVolumeMounts))
700729
mounts = append(mounts, corev1.VolumeMount{
701730
Name: DataVolumeName,
702731
MountPath: DataMountPath,
703732
})
733+
if brandingConfigMapRef(instance) != nil {
734+
mounts = append(mounts, corev1.VolumeMount{
735+
Name: BrandVolumeName,
736+
MountPath: BrandMountPath,
737+
ReadOnly: true,
738+
})
739+
}
704740
mounts = append(mounts, instance.Spec.ExtraVolumeMounts...)
705741
return mounts
706742
}

0 commit comments

Comments
 (0)