Skip to content

Commit 5bb11f0

Browse files
stubbiclaude
andauthored
feat: Tier 3 cross-pollination - PaperclipClusterDefaults + PaperclipSelfConfig CRDs + Tailscale sidecar (#68)
* feat(clusterdefaults): add cluster-scoped PaperclipClusterDefaults CRD Add a cluster-scoped singleton (name must be "cluster") that supplies org-wide defaults merged into every Instance at reconcile time. The merge happens in-memory only; the user's stored spec in etcd is never overwritten and per-instance fields always win. Defaults cover image, storage class, database mode, observability, networking Service type, and shared env vars (merged by Name, instance entries win). The Instance reconciler fetches the singleton, applies ApplyClusterDefaults before rendering owned resources, and watches the singleton to re-reconcile Instances. A dedicated PaperclipClusterDefaults controller validates the singleton name and surfaces a Ready/InvalidName condition. Also wires the cross-cutting generated artifacts (manifests, deepcopy, chart CRDs, RBAC, samples, docs) and adds the spec fields and controller registration shared by the Tier 3 work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(selfconfig): add agent-driven PaperclipSelfConfig CRD applied via SSA Add a namespaced PaperclipSelfConfig CRD that lets the Paperclip app request changes to its own parent Instance (addPlugins/removePlugins, patchConfig, addEnvVars/removeEnvVars). Requests are gated by the parent Instance's spec.selfConfigure allowlist (enabled + allowedActions) and a set of protected config keys and protected env vars so an agent cannot touch auth, secrets, database, or operator-managed env. Approved changes are applied to the Instance via Server-Side Apply with a dedicated field manager ("paperclip-selfconfig") so GitOps controllers do not flap over the agent-owned fields. The controller records audit events, sets a terminal Pending/Applied/Failed/Denied phase, owns the request via an owner reference, and TTL-reaps completed requests after one hour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tailscale): add ephemeral Tailscale sidecar that Serves the app Add spec.tailscale (enabled, mode=serve|funnel, image, authKey.secretRef, hostname) and an ephemeral userspace Tailscale sidecar that Serves the Paperclip app (port 3100) over the tailnet via TS_SERVE_CONFIG. The node runs with --ephemeral so it is removed from the tailnet when the pod is deleted, and the sidecar runs with a read-only root filesystem and all capabilities dropped. The serve config is rendered into a managed ConfigMap and mounted into the sidecar; funnel mode additionally sets AllowFunnel. The Instance reconciler provisions the ConfigMap (surfacing a TailscaleReady condition) and the NetworkPolicy gains STUN (3478/udp) and WireGuard (41641/udp) egress when Tailscale is enabled (443/tcp for DERP/control is already allowed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 97b9d0f commit 5bb11f0

38 files changed

Lines changed: 4665 additions & 2 deletions

PROJECT

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,22 @@ resources:
2020
kind: Instance
2121
path: github.com/paperclipinc/paperclip-operator/api/v1alpha1
2222
version: v1alpha1
23+
- api:
24+
crdVersion: v1
25+
namespaced: false
26+
controller: true
27+
domain: inc
28+
group: paperclip
29+
kind: PaperclipClusterDefaults
30+
path: github.com/paperclipinc/paperclip-operator/api/v1alpha1
31+
version: v1alpha1
32+
- api:
33+
crdVersion: v1
34+
namespaced: true
35+
controller: true
36+
domain: inc
37+
group: paperclip
38+
kind: PaperclipSelfConfig
39+
path: github.com/paperclipinc/paperclip-operator/api/v1alpha1
40+
version: v1alpha1
2341
version: "3"
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
Copyright 2026.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import (
20+
corev1 "k8s.io/api/core/v1"
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22+
)
23+
24+
// ClusterDefaultsSingletonName is the only accepted name for the cluster-scoped
25+
// PaperclipClusterDefaults singleton. A PaperclipClusterDefaults under any other
26+
// name is ignored by the operator and reported as Invalid in its status.
27+
const ClusterDefaultsSingletonName = "cluster"
28+
29+
// PaperclipClusterDefaultsSpec defines cluster-wide defaults that the operator
30+
// merges into every Instance at reconcile time. Per-instance fields always win:
31+
// a default is only applied when the corresponding instance field is unset.
32+
type PaperclipClusterDefaultsSpec struct {
33+
// Image is the default container image configuration applied to instances
34+
// where the corresponding instance fields are unset. Each sub-field is
35+
// merged independently (e.g. a cluster-default tag still applies even when
36+
// the instance sets its own repository).
37+
// +optional
38+
Image ImageSpec `json:"image,omitempty"`
39+
40+
// StorageClass is the default storage class applied to the Paperclip data
41+
// PVC, the managed PostgreSQL PVC, and the managed Redis PVC when those
42+
// fields are unset on the instance.
43+
// +optional
44+
StorageClass string `json:"storageClass,omitempty"`
45+
46+
// DatabaseMode is the default database mode ("embedded", "external", or
47+
// "managed") applied to instances where spec.database.mode is unset.
48+
// +kubebuilder:validation:Enum=embedded;external;managed
49+
// +optional
50+
DatabaseMode string `json:"databaseMode,omitempty"`
51+
52+
// Observability configures cluster-wide observability defaults that are
53+
// merged into instances where the corresponding fields are unset.
54+
// +optional
55+
Observability ObservabilitySpec `json:"observability,omitempty"`
56+
57+
// Networking configures cluster-wide networking defaults. Currently only
58+
// the default Service type is merged when the instance leaves it unset.
59+
// +optional
60+
Networking NetworkingSpec `json:"networking,omitempty"`
61+
62+
// Env is a list of default environment variables merged into every
63+
// instance's container env. Instance-level env entries with the same Name
64+
// override the cluster default for that name. Defaults appear first in the
65+
// resulting env list, followed by instance-only names.
66+
// +listType=map
67+
// +listMapKey=name
68+
// +optional
69+
Env []corev1.EnvVar `json:"env,omitempty"`
70+
}
71+
72+
// PaperclipClusterDefaultsStatus reports which singleton (if any) is currently
73+
// being applied by the operator.
74+
type PaperclipClusterDefaultsStatus struct {
75+
// Conditions describes the current state of the singleton, including
76+
// whether the name matches the expected "cluster" singleton.
77+
// +optional
78+
// +listType=map
79+
// +listMapKey=type
80+
Conditions []metav1.Condition `json:"conditions,omitempty"`
81+
82+
// ObservedGeneration is the generation of the spec most recently
83+
// processed by the operator.
84+
// +optional
85+
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
86+
}
87+
88+
// +kubebuilder:object:root=true
89+
// +kubebuilder:subresource:status
90+
// +kubebuilder:resource:scope=Cluster,shortName=pccd
91+
// +kubebuilder:printcolumn:name="DatabaseMode",type=string,JSONPath=`.spec.databaseMode`
92+
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
93+
94+
// PaperclipClusterDefaults is a cluster-scoped singleton (name must be "cluster")
95+
// that provides default values merged into every Instance at reconcile time. It
96+
// gives platform operators a single source of truth for org-wide image, storage
97+
// class, database mode, observability, networking, and shared environment-variable
98+
// defaults without duplicating the same boilerplate in every Instance manifest.
99+
//
100+
// Precedence: per-instance fields always win over cluster defaults. A default is
101+
// only applied when the corresponding instance field is unset. The merged values
102+
// are used only for rendering owned resources; the user's stored spec in etcd is
103+
// never overwritten.
104+
type PaperclipClusterDefaults struct {
105+
metav1.TypeMeta `json:",inline"`
106+
metav1.ObjectMeta `json:"metadata,omitempty"`
107+
108+
Spec PaperclipClusterDefaultsSpec `json:"spec,omitempty"`
109+
Status PaperclipClusterDefaultsStatus `json:"status,omitempty"`
110+
}
111+
112+
// +kubebuilder:object:root=true
113+
114+
// PaperclipClusterDefaultsList contains a list of PaperclipClusterDefaults.
115+
type PaperclipClusterDefaultsList struct {
116+
metav1.TypeMeta `json:",inline"`
117+
metav1.ListMeta `json:"metadata,omitempty"`
118+
Items []PaperclipClusterDefaults `json:"items"`
119+
}
120+
121+
func init() {
122+
SchemeBuilder.Register(&PaperclipClusterDefaults{}, &PaperclipClusterDefaultsList{})
123+
}

api/v1alpha1/paperclipinstance_types.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ type InstanceSpec struct {
7373
// +optional
7474
Plugins []PluginRef `json:"plugins,omitempty"`
7575

76+
// SelfConfigure enables agents to modify their own Instance via
77+
// PaperclipSelfConfig resources, gated by an action allowlist.
78+
// +optional
79+
SelfConfigure SelfConfigureSpec `json:"selfConfigure,omitempty"`
80+
7681
// Env specifies additional environment variables for the Paperclip container.
7782
// +optional
7883
Env []corev1.EnvVar `json:"env,omitempty"`
@@ -135,6 +140,11 @@ type InstanceSpec struct {
135140
// +optional
136141
RestoreFrom string `json:"restoreFrom,omitempty"`
137142

143+
// Tailscale configures an ephemeral Tailscale sidecar that Serves the
144+
// Paperclip app over the tailnet.
145+
// +optional
146+
Tailscale TailscaleSpec `json:"tailscale,omitempty"`
147+
138148
// Sidecars specifies additional sidecar containers.
139149
// +optional
140150
Sidecars []corev1.Container `json:"sidecars,omitempty"`
@@ -582,6 +592,72 @@ type ConnectionsSpec struct {
582592
ProvidersConfigRef *corev1.LocalObjectReference `json:"providersConfigRef,omitempty"`
583593
}
584594

595+
// TailscaleSpec configures an ephemeral Tailscale sidecar for secure tailnet
596+
// access. When enabled, a userspace tailscaled sidecar runs alongside the
597+
// Paperclip container and Serves the app (port 3100) over the tailnet via
598+
// TS_SERVE_CONFIG. Use an ephemeral, reusable auth key from the Tailscale admin
599+
// console so the node is automatically removed when the pod is deleted.
600+
type TailscaleSpec struct {
601+
// Enabled enables the Tailscale sidecar.
602+
// +kubebuilder:default=false
603+
// +optional
604+
Enabled bool `json:"enabled,omitempty"`
605+
606+
// Mode selects the Tailscale exposure mode.
607+
// "serve" exposes the instance to tailnet members only (default).
608+
// "funnel" exposes the instance to the public internet via Tailscale Funnel.
609+
// +kubebuilder:validation:Enum=serve;funnel
610+
// +kubebuilder:default="serve"
611+
// +optional
612+
Mode string `json:"mode,omitempty"`
613+
614+
// Image configures the Tailscale sidecar container image.
615+
// +optional
616+
Image TailscaleImageSpec `json:"image,omitempty"`
617+
618+
// AuthKey references a Secret containing the Tailscale auth key. The Secret
619+
// must have a key matching AuthKey.Key (default: "authkey"). Use an
620+
// ephemeral+reusable key from the Tailscale admin console.
621+
// +optional
622+
AuthKey *TailscaleAuthKeySpec `json:"authKey,omitempty"`
623+
624+
// Hostname sets the Tailscale device name (defaults to the instance name).
625+
// +optional
626+
Hostname string `json:"hostname,omitempty"`
627+
628+
// Resources specifies compute resources for the Tailscale sidecar container.
629+
// +optional
630+
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
631+
}
632+
633+
// TailscaleAuthKeySpec references a Secret key holding the Tailscale auth key.
634+
type TailscaleAuthKeySpec struct {
635+
// SecretRef references the Secret containing the auth key.
636+
SecretRef corev1.LocalObjectReference `json:"secretRef"`
637+
638+
// Key is the key within the referenced Secret. Defaults to "authkey".
639+
// +kubebuilder:default="authkey"
640+
// +optional
641+
Key string `json:"key,omitempty"`
642+
}
643+
644+
// TailscaleImageSpec defines the Tailscale sidecar container image.
645+
type TailscaleImageSpec struct {
646+
// Repository is the container image repository.
647+
// +kubebuilder:default="ghcr.io/tailscale/tailscale"
648+
// +optional
649+
Repository string `json:"repository,omitempty"`
650+
651+
// Tag is the container image tag.
652+
// +kubebuilder:default="stable"
653+
// +optional
654+
Tag string `json:"tag,omitempty"`
655+
656+
// Digest is the container image digest for supply-chain security.
657+
// +optional
658+
Digest string `json:"digest,omitempty"`
659+
}
660+
585661
// PluginRef references a Paperclip plugin.
586662
type PluginRef struct {
587663
// Name is the plugin package name.

0 commit comments

Comments
 (0)