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
18 changes: 18 additions & 0 deletions api/v1alpha1/paperclipinstance_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ type InstanceSpec struct {
// +optional
ObjectStorage *ObjectStorageSpec `json:"objectStorage,omitempty"`

// Branding configures optional UI brand theming for the Paperclip product.
// +optional
Branding *BrandingSpec `json:"branding,omitempty"`

// Heartbeat configures the agent heartbeat scheduler.
// +optional
Heartbeat HeartbeatSpec `json:"heartbeat,omitempty"`
Expand Down Expand Up @@ -513,6 +517,20 @@ type ObjectStorageSpec struct {
ForcePathStyle *bool `json:"forcePathStyle,omitempty"`
}

// BrandingSpec configures optional UI brand theming. When set, the operator
// mounts the referenced ConfigMap into the Paperclip container and points the
// server at it via PAPERCLIP_BRAND_DIR. The server then serves the directory
// under /branding and loads /branding/brand.css after the bundled stylesheet,
// so the ConfigMap's brand.css can override the product's CSS variables without
// rebuilding the image.
type BrandingSpec struct {
// CSSConfigMapRef references a ConfigMap whose keys are mounted as files in
// the brand directory. Provide a "brand.css" key for the runtime brand
// stylesheet; additional keys (e.g. fonts referenced by relative URL) are
// mounted alongside it.
CSSConfigMapRef *corev1.LocalObjectReference `json:"cssConfigMapRef,omitempty"`
}

// HeartbeatSpec configures the agent heartbeat scheduler.
type HeartbeatSpec struct {
// Enabled controls whether the heartbeat scheduler runs. Defaults to true.
Expand Down
25 changes: 25 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -1888,6 +1888,29 @@ spec:
Omit to use only app-native backups.
type: string
type: object
branding:
description: Branding configures optional UI brand theming for the
Paperclip product.
properties:
cssConfigMapRef:
description: |-
CSSConfigMapRef references a ConfigMap whose keys are mounted as files in
the brand directory. Provide a "brand.css" key for the runtime brand
stylesheet; additional keys (e.g. fonts referenced by relative URL) are
mounted alongside it.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
type: object
connections:
description: |-
Connections configures third-party OAuth provider credentials for
Expand Down
23 changes: 23 additions & 0 deletions config/crd/bases/paperclip.inc_instances.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1882,6 +1882,29 @@ spec:
Omit to use only app-native backups.
type: string
type: object
branding:
description: Branding configures optional UI brand theming for the
Paperclip product.
properties:
cssConfigMapRef:
description: |-
CSSConfigMapRef references a ConfigMap whose keys are mounted as files in
the brand directory. Provide a "brand.css" key for the runtime brand
stylesheet; additional keys (e.g. fonts referenced by relative URL) are
mounted alongside it.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
type: object
connections:
description: |-
Connections configures third-party OAuth provider credentials for
Expand Down
87 changes: 87 additions & 0 deletions internal/resources/branding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package resources

import (
"testing"

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

paperclipv1alpha1 "github.com/paperclipinc/paperclip-operator/api/v1alpha1"
)

func findVolume(volumes []corev1.Volume, name string) *corev1.Volume {
for i := range volumes {
if volumes[i].Name == name {
return &volumes[i]
}
}
return nil
}

func findMount(mounts []corev1.VolumeMount, name string) *corev1.VolumeMount {
for i := range mounts {
if mounts[i].Name == name {
return &mounts[i]
}
}
return nil
}

func TestBuildStatefulSetNoBrandingByDefault(t *testing.T) {
instance := newTestInstance("my-paperclip")
sts := BuildStatefulSet(instance, nil)

if v := findVolume(sts.Spec.Template.Spec.Volumes, BrandVolumeName); v != nil {
t.Errorf("expected no brand volume when branding unset, got %+v", v)
}

container := sts.Spec.Template.Spec.Containers[0]
if m := findMount(container.VolumeMounts, BrandVolumeName); m != nil {
t.Errorf("expected no brand volume mount when branding unset, got %+v", m)
}
for _, env := range container.Env {
if env.Name == EnvBrandDir {
t.Errorf("expected no %s env when branding unset", EnvBrandDir)
}
}
}

func TestBuildStatefulSetBrandingWiring(t *testing.T) {
instance := newTestInstance("my-paperclip")
instance.Spec.Branding = &paperclipv1alpha1.BrandingSpec{
CSSConfigMapRef: &corev1.LocalObjectReference{Name: "paperclip-brand-css"},
}
sts := BuildStatefulSet(instance, nil)

vol := findVolume(sts.Spec.Template.Spec.Volumes, BrandVolumeName)
if vol == nil {
t.Fatalf("expected a %q volume", BrandVolumeName)
}
if vol.ConfigMap == nil {
t.Fatalf("expected brand volume to be backed by a ConfigMap, got %+v", vol.VolumeSource)
}
if vol.ConfigMap.Name != "paperclip-brand-css" {
t.Errorf("expected brand ConfigMap name paperclip-brand-css, got %q", vol.ConfigMap.Name)
}

container := sts.Spec.Template.Spec.Containers[0]
mount := findMount(container.VolumeMounts, BrandVolumeName)
if mount == nil {
t.Fatalf("expected a %q volume mount on the main container", BrandVolumeName)
}
if mount.MountPath != BrandMountPath {
t.Errorf("expected brand mount path %q, got %q", BrandMountPath, mount.MountPath)
}
if !mount.ReadOnly {
t.Error("expected the brand mount to be read-only")
}

var brandDir string
for _, env := range container.Env {
if env.Name == EnvBrandDir {
brandDir = env.Value
}
}
if brandDir != BrandMountPath {
t.Errorf("expected %s=%s, got %q", EnvBrandDir, BrandMountPath, brandDir)
}
}
7 changes: 7 additions & 0 deletions internal/resources/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ const (
DataVolumeName = "paperclip-data"
// DataMountPath is the mount path for the Paperclip data volume.
DataMountPath = "/paperclip"
// BrandVolumeName is the name of the optional brand-assets volume.
BrandVolumeName = "paperclip-branding"
// BrandMountPath is the read-only mount path for the brand-assets ConfigMap.
// The server serves this directory under /branding (PAPERCLIP_BRAND_DIR).
BrandMountPath = "/etc/paperclip/branding"
// EnvBrandDir is the environment variable pointing the server at the brand dir.
EnvBrandDir = "PAPERCLIP_BRAND_DIR"
// DatabaseVolumeName is the name of the PostgreSQL data volume.
DatabaseVolumeName = "pgdata"
// DatabaseMountPath is the mount path for the PostgreSQL data volume.
Expand Down
38 changes: 37 additions & 1 deletion internal/resources/statefulset.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,12 @@ func buildEnvVars(instance *paperclipv1alpha1.Instance) []corev1.EnvVar {
}
}

// Brand theming: point the server at the mounted brand directory so it
// serves /branding/brand.css and loads it after the bundled stylesheet.
if brandingConfigMapRef(instance) != nil {
vars = append(vars, corev1.EnvVar{Name: EnvBrandDir, Value: BrandMountPath})
}

// LLM API keys
if instance.Spec.Adapters.APIKeysSecretRef != nil {
vars = append(vars, corev1.EnvVar{
Expand Down Expand Up @@ -692,15 +698,45 @@ func buildVolumes(instance *paperclipv1alpha1.Instance) []corev1.Volume {
})
}

// Optional brand-assets volume: a ConfigMap of brand files (brand.css, ...)
// mounted read-only and served by the app under /branding.
if ref := brandingConfigMapRef(instance); ref != nil {
volumes = append(volumes, corev1.Volume{
Name: BrandVolumeName,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: *ref,
},
},
})
}

return volumes
}

// brandingConfigMapRef returns the brand ConfigMap reference when branding is
// configured, or nil. Centralized so the volume, mount, and env wiring stay
// in sync (mirrors the spec.ObjectStorage != nil gating pattern).
func brandingConfigMapRef(instance *paperclipv1alpha1.Instance) *corev1.LocalObjectReference {
if instance.Spec.Branding == nil {
return nil
}
return instance.Spec.Branding.CSSConfigMapRef
}

func buildVolumeMounts(instance *paperclipv1alpha1.Instance) []corev1.VolumeMount {
mounts := make([]corev1.VolumeMount, 0, 1+len(instance.Spec.ExtraVolumeMounts))
mounts := make([]corev1.VolumeMount, 0, 2+len(instance.Spec.ExtraVolumeMounts))
mounts = append(mounts, corev1.VolumeMount{
Name: DataVolumeName,
MountPath: DataMountPath,
})
if brandingConfigMapRef(instance) != nil {
mounts = append(mounts, corev1.VolumeMount{
Name: BrandVolumeName,
MountPath: BrandMountPath,
ReadOnly: true,
})
}
mounts = append(mounts, instance.Spec.ExtraVolumeMounts...)
return mounts
}
Expand Down
Loading