Skip to content

Commit 5c7cfca

Browse files
stubbiclaude
andauthored
feat: cloud sandbox support — RBAC, CRD, and env var injection (#9)
* feat: add cloud sandbox support — RBAC, CRD, and env var injection Extends the operator to support cloud sandbox agent execution: CRD: - CloudSandboxSpec on AdaptersSpec with enabled, defaultImage, namespace, idleTimeoutMin, and resources fields RBAC: - Namespace-scoped Role granting pods (create/get/list/watch/delete/patch), pods/exec (create), and pods/log (get) - RoleBinding connecting the sandbox Role to the instance ServiceAccount - Separate from operator's own RBAC — scoped to sandbox namespace only StatefulSet: - Injects PAPERCLIP_CLOUD_SANDBOX_ENABLED, _NAMESPACE, _DEFAULT_IMAGE, _IDLE_TIMEOUT_MIN env vars when cloud sandbox is enabled Reconciler: - reconcileSandboxRBAC step between ServiceAccount and Database - Creates/updates Role + RoleBinding via CreateOrUpdate pattern Tests: - 4 new tests covering env var injection, RBAC role/binding construction - All existing tests pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add networkpolicies permission to sandbox RBAC role The cloud sandbox adapter creates a NetworkPolicy to restrict pod egress to DNS, HTTPS, SSH, and the Paperclip server only. The sandbox Role needs create/get/update/patch for networkpolicies to manage this. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sync Helm chart RBAC with kubebuilder markers for roles/rolebindings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 65d3d72 commit 5c7cfca

10 files changed

Lines changed: 464 additions & 0 deletions

File tree

api/v1alpha1/paperclipinstance_types.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,36 @@ type AdaptersSpec struct {
352352
// The Secret should contain keys like ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
353353
// +optional
354354
APIKeysSecretRef *corev1.LocalObjectReference `json:"apiKeysSecretRef,omitempty"`
355+
356+
// CloudSandbox configures cloud-based agent execution in isolated Kubernetes pods.
357+
// +optional
358+
CloudSandbox *CloudSandboxSpec `json:"cloudSandbox,omitempty"`
359+
}
360+
361+
// CloudSandboxSpec configures cloud sandbox execution for agent runtimes.
362+
type CloudSandboxSpec struct {
363+
// Enabled controls whether cloud sandbox execution is available.
364+
// +kubebuilder:default=false
365+
// +optional
366+
Enabled bool `json:"enabled,omitempty"`
367+
368+
// DefaultImage is the default agent runtime container image.
369+
// +kubebuilder:default="ghcr.io/paperclipinc/agent-multi:latest"
370+
// +optional
371+
DefaultImage string `json:"defaultImage,omitempty"`
372+
373+
// Namespace is the namespace for sandbox pods. Defaults to the instance namespace.
374+
// +optional
375+
Namespace string `json:"namespace,omitempty"`
376+
377+
// IdleTimeoutMin is how long (in minutes) a sandbox pod can be idle before being reaped.
378+
// +kubebuilder:default=30
379+
// +optional
380+
IdleTimeoutMin int32 `json:"idleTimeoutMin,omitempty"`
381+
382+
// Resources specifies default compute resources for sandbox pods.
383+
// +optional
384+
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
355385
}
356386

357387
// ConnectionsSpec configures third-party OAuth provider credentials.

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 21 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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,91 @@ spec:
7676
type: string
7777
type: object
7878
x-kubernetes-map-type: atomic
79+
cloudSandbox:
80+
description: CloudSandbox configures cloud-based agent execution
81+
in isolated Kubernetes pods.
82+
properties:
83+
defaultImage:
84+
default: ghcr.io/paperclipinc/agent-multi:latest
85+
description: DefaultImage is the default agent runtime container
86+
image.
87+
type: string
88+
enabled:
89+
default: false
90+
description: Enabled controls whether cloud sandbox execution
91+
is available.
92+
type: boolean
93+
idleTimeoutMin:
94+
default: 30
95+
description: IdleTimeoutMin is how long (in minutes) a sandbox
96+
pod can be idle before being reaped.
97+
format: int32
98+
type: integer
99+
namespace:
100+
description: Namespace is the namespace for sandbox pods.
101+
Defaults to the instance namespace.
102+
type: string
103+
resources:
104+
description: Resources specifies default compute resources
105+
for sandbox pods.
106+
properties:
107+
claims:
108+
description: |-
109+
Claims lists the names of resources, defined in spec.resourceClaims,
110+
that are used by this container.
111+
112+
This is an alpha field and requires enabling the
113+
DynamicResourceAllocation feature gate.
114+
115+
This field is immutable. It can only be set for containers.
116+
items:
117+
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
118+
properties:
119+
name:
120+
description: |-
121+
Name must match the name of one entry in pod.spec.resourceClaims of
122+
the Pod where this field is used. It makes that resource available
123+
inside a container.
124+
type: string
125+
request:
126+
description: |-
127+
Request is the name chosen for a request in the referenced claim.
128+
If empty, everything from the claim is made available, otherwise
129+
only the result of this request.
130+
type: string
131+
required:
132+
- name
133+
type: object
134+
type: array
135+
x-kubernetes-list-map-keys:
136+
- name
137+
x-kubernetes-list-type: map
138+
limits:
139+
additionalProperties:
140+
anyOf:
141+
- type: integer
142+
- type: string
143+
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
144+
x-kubernetes-int-or-string: true
145+
description: |-
146+
Limits describes the maximum amount of compute resources allowed.
147+
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
148+
type: object
149+
requests:
150+
additionalProperties:
151+
anyOf:
152+
- type: integer
153+
- type: string
154+
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
155+
x-kubernetes-int-or-string: true
156+
description: |-
157+
Requests describes the minimum amount of compute resources required.
158+
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
159+
otherwise to an implementation-defined value. Requests cannot exceed Limits.
160+
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
161+
type: object
162+
type: object
163+
type: object
79164
type: object
80165
auth:
81166
description: Auth configures authentication settings.

charts/paperclip-operator/templates/rbac.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ rules:
4848
- apiGroups: ["batch"]
4949
resources: ["jobs"]
5050
verbs: ["get", "list", "watch", "create", "delete"]
51+
# RBAC (sandbox pod permissions)
52+
- apiGroups: ["rbac.authorization.k8s.io"]
53+
resources: ["roles", "rolebindings"]
54+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
5155
---
5256
apiVersion: rbac.authorization.k8s.io/v1
5357
kind: ClusterRoleBinding

config/crd/bases/paperclip.inc_instances.yaml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,91 @@ spec:
7070
type: string
7171
type: object
7272
x-kubernetes-map-type: atomic
73+
cloudSandbox:
74+
description: CloudSandbox configures cloud-based agent execution
75+
in isolated Kubernetes pods.
76+
properties:
77+
defaultImage:
78+
default: ghcr.io/paperclipinc/agent-multi:latest
79+
description: DefaultImage is the default agent runtime container
80+
image.
81+
type: string
82+
enabled:
83+
default: false
84+
description: Enabled controls whether cloud sandbox execution
85+
is available.
86+
type: boolean
87+
idleTimeoutMin:
88+
default: 30
89+
description: IdleTimeoutMin is how long (in minutes) a sandbox
90+
pod can be idle before being reaped.
91+
format: int32
92+
type: integer
93+
namespace:
94+
description: Namespace is the namespace for sandbox pods.
95+
Defaults to the instance namespace.
96+
type: string
97+
resources:
98+
description: Resources specifies default compute resources
99+
for sandbox pods.
100+
properties:
101+
claims:
102+
description: |-
103+
Claims lists the names of resources, defined in spec.resourceClaims,
104+
that are used by this container.
105+
106+
This is an alpha field and requires enabling the
107+
DynamicResourceAllocation feature gate.
108+
109+
This field is immutable. It can only be set for containers.
110+
items:
111+
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
112+
properties:
113+
name:
114+
description: |-
115+
Name must match the name of one entry in pod.spec.resourceClaims of
116+
the Pod where this field is used. It makes that resource available
117+
inside a container.
118+
type: string
119+
request:
120+
description: |-
121+
Request is the name chosen for a request in the referenced claim.
122+
If empty, everything from the claim is made available, otherwise
123+
only the result of this request.
124+
type: string
125+
required:
126+
- name
127+
type: object
128+
type: array
129+
x-kubernetes-list-map-keys:
130+
- name
131+
x-kubernetes-list-type: map
132+
limits:
133+
additionalProperties:
134+
anyOf:
135+
- type: integer
136+
- type: string
137+
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
138+
x-kubernetes-int-or-string: true
139+
description: |-
140+
Limits describes the maximum amount of compute resources allowed.
141+
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
142+
type: object
143+
requests:
144+
additionalProperties:
145+
anyOf:
146+
- type: integer
147+
- type: string
148+
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
149+
x-kubernetes-int-or-string: true
150+
description: |-
151+
Requests describes the minimum amount of compute resources required.
152+
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
153+
otherwise to an implementation-defined value. Requests cannot exceed Limits.
154+
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
155+
type: object
156+
type: object
157+
type: object
73158
type: object
74159
auth:
75160
description: Auth configures authentication settings.

config/rbac/role.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,16 @@ rules:
130130
- patch
131131
- update
132132
- watch
133+
- apiGroups:
134+
- rbac.authorization.k8s.io
135+
resources:
136+
- rolebindings
137+
- roles
138+
verbs:
139+
- create
140+
- delete
141+
- get
142+
- list
143+
- patch
144+
- update
145+
- watch

internal/controller/instance_controller.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
batchv1 "k8s.io/api/batch/v1"
2828
corev1 "k8s.io/api/core/v1"
2929
networkingv1 "k8s.io/api/networking/v1"
30+
rbacv1 "k8s.io/api/rbac/v1"
3031
apierrors "k8s.io/apimachinery/pkg/api/errors"
3132
"k8s.io/apimachinery/pkg/api/meta"
3233
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -85,6 +86,7 @@ type InstanceReconciler struct {
8586
// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete
8687
// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete
8788
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete
89+
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
8890

8991
// Reconcile moves the cluster state toward the desired state defined by the Instance CR.
9092
//
@@ -163,6 +165,13 @@ func (r *InstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
163165
}
164166
}
165167

168+
// 1.5. Sandbox RBAC (if cloud sandbox enabled)
169+
if cs := instance.Spec.Adapters.CloudSandbox; cs != nil && cs.Enabled {
170+
if err := r.reconcileSandboxRBAC(ctx, instance); err != nil {
171+
return r.handleError(ctx, instance, "SandboxRBAC", err)
172+
}
173+
}
174+
166175
// 2. Database (if managed)
167176
if instance.Spec.Database.Mode == "managed" || instance.Spec.Database.Mode == "" {
168177
if err := r.reconcileManagedDatabase(ctx, instance); err != nil {
@@ -275,6 +284,51 @@ func (r *InstanceReconciler) reconcileServiceAccount(ctx context.Context, instan
275284
return nil
276285
}
277286

287+
func (r *InstanceReconciler) reconcileSandboxRBAC(ctx context.Context, instance *paperclipv1alpha1.Instance) error {
288+
cs := instance.Spec.Adapters.CloudSandbox
289+
namespace := cs.Namespace
290+
if namespace == "" {
291+
namespace = instance.Namespace
292+
}
293+
294+
// Role
295+
desiredRole := resources.BuildSandboxRole(instance, namespace)
296+
roleObj := &rbacv1.Role{
297+
ObjectMeta: metav1.ObjectMeta{
298+
Name: desiredRole.Name,
299+
Namespace: desiredRole.Namespace,
300+
},
301+
}
302+
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, roleObj, func() error {
303+
roleObj.Labels = desiredRole.Labels
304+
roleObj.Rules = desiredRole.Rules
305+
return controllerutil.SetControllerReference(instance, roleObj, r.Scheme)
306+
})
307+
if err != nil {
308+
return fmt.Errorf("reconciling sandbox Role: %w", err)
309+
}
310+
311+
// RoleBinding
312+
desiredBinding := resources.BuildSandboxRoleBinding(instance, namespace)
313+
bindingObj := &rbacv1.RoleBinding{
314+
ObjectMeta: metav1.ObjectMeta{
315+
Name: desiredBinding.Name,
316+
Namespace: desiredBinding.Namespace,
317+
},
318+
}
319+
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, bindingObj, func() error {
320+
bindingObj.Labels = desiredBinding.Labels
321+
bindingObj.RoleRef = desiredBinding.RoleRef
322+
bindingObj.Subjects = desiredBinding.Subjects
323+
return controllerutil.SetControllerReference(instance, bindingObj, r.Scheme)
324+
})
325+
if err != nil {
326+
return fmt.Errorf("reconciling sandbox RoleBinding: %w", err)
327+
}
328+
329+
return nil
330+
}
331+
278332
func (r *InstanceReconciler) reconcileManagedDatabase(ctx context.Context, instance *paperclipv1alpha1.Instance) error {
279333
// Ensure database credentials secret exists
280334
if err := r.ensureDatabaseSecret(ctx, instance); err != nil {
@@ -773,6 +827,8 @@ func (r *InstanceReconciler) SetupWithManager(mgr ctrl.Manager) error {
773827
Owns(&corev1.Secret{}).
774828
Owns(&networkingv1.Ingress{}).
775829
Owns(&networkingv1.NetworkPolicy{}).
830+
Owns(&rbacv1.Role{}).
831+
Owns(&rbacv1.RoleBinding{}).
776832
Named("instance").
777833
Complete(r)
778834
}

0 commit comments

Comments
 (0)