Skip to content

Commit f21a965

Browse files
stubbiclaude
andauthored
feat(crd): perTenantQuota/perTenantLimitRange on K8sExecutionSpec (#94)
* feat(crd): perTenantQuota/perTenantLimitRange on K8sExecutionSpec + PAPERCLIP_K8S_QUOTA_* env Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lint): behaviour -> behavior (misspell) in perTenantQuota doc comment * chore: regen CRDs to match behavior spelling fix --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 03040ec commit f21a965

6 files changed

Lines changed: 246 additions & 0 deletions

File tree

api/v1alpha1/paperclipinstance_types.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,49 @@ type K8sExecutionSpec struct {
667667
// Maps to PAPERCLIP_K8S_NAMESPACE_PREFIX.
668668
// +optional
669669
NamespacePrefix string `json:"namespacePrefix,omitempty"`
670+
671+
// PerTenantQuota stamps a ResourceQuota on each per-tenant namespace, bounding total
672+
// CPU/memory/pods so one tenant cannot starve the shared sandbox pool. Absent = no quota
673+
// (today's behavior). Maps to PAPERCLIP_K8S_QUOTA_* env consumed by the sandbox plugin.
674+
// +optional
675+
PerTenantQuota *TenantResourceQuota `json:"perTenantQuota,omitempty"`
676+
677+
// PerTenantLimitRange stamps a LimitRange on each per-tenant namespace (per-container
678+
// default + max ceiling). Absent = no LimitRange. Maps to PAPERCLIP_K8S_LIMITRANGE_* env.
679+
// +optional
680+
PerTenantLimitRange *TenantLimitRange `json:"perTenantLimitRange,omitempty"`
681+
}
682+
683+
// TenantResourceQuota mirrors the hard limits of a v1 ResourceQuota for a per-tenant
684+
// namespace. All values are Kubernetes quantity strings.
685+
type TenantResourceQuota struct {
686+
// +kubebuilder:validation:Required
687+
Pods string `json:"pods"`
688+
// +kubebuilder:validation:Required
689+
RequestsCPU string `json:"requestsCpu"`
690+
// +kubebuilder:validation:Required
691+
RequestsMemory string `json:"requestsMemory"`
692+
// +kubebuilder:validation:Required
693+
LimitsCPU string `json:"limitsCpu"`
694+
// +kubebuilder:validation:Required
695+
LimitsMemory string `json:"limitsMemory"`
696+
}
697+
698+
// TenantLimitRange mirrors a Container-type v1 LimitRange for a per-tenant namespace:
699+
// per-container default, defaultRequest, and max.
700+
type TenantLimitRange struct {
701+
// +kubebuilder:validation:Required
702+
DefaultCPU string `json:"defaultCpu"`
703+
// +kubebuilder:validation:Required
704+
DefaultMemory string `json:"defaultMemory"`
705+
// +kubebuilder:validation:Required
706+
DefaultRequestCPU string `json:"defaultRequestCpu"`
707+
// +kubebuilder:validation:Required
708+
DefaultRequestMem string `json:"defaultRequestMemory"`
709+
// +kubebuilder:validation:Required
710+
MaxCPU string `json:"maxCpu"`
711+
// +kubebuilder:validation:Required
712+
MaxMemory string `json:"maxMemory"`
670713
}
671714

672715
// AdapterRegistryEntry is one declarative agent-harness entry. Mirrors the

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 40 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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,54 @@ spec:
366366
letting multiple instances share a cluster without namespace collisions.
367367
Maps to PAPERCLIP_K8S_NAMESPACE_PREFIX.
368368
type: string
369+
perTenantLimitRange:
370+
description: |-
371+
PerTenantLimitRange stamps a LimitRange on each per-tenant namespace (per-container
372+
default + max ceiling). Absent = no LimitRange. Maps to PAPERCLIP_K8S_LIMITRANGE_* env.
373+
properties:
374+
defaultCpu:
375+
type: string
376+
defaultMemory:
377+
type: string
378+
defaultRequestCpu:
379+
type: string
380+
defaultRequestMemory:
381+
type: string
382+
maxCpu:
383+
type: string
384+
maxMemory:
385+
type: string
386+
required:
387+
- defaultCpu
388+
- defaultMemory
389+
- defaultRequestCpu
390+
- defaultRequestMemory
391+
- maxCpu
392+
- maxMemory
393+
type: object
394+
perTenantQuota:
395+
description: |-
396+
PerTenantQuota stamps a ResourceQuota on each per-tenant namespace, bounding total
397+
CPU/memory/pods so one tenant cannot starve the shared sandbox pool. Absent = no quota
398+
(today's behavior). Maps to PAPERCLIP_K8S_QUOTA_* env consumed by the sandbox plugin.
399+
properties:
400+
limitsCpu:
401+
type: string
402+
limitsMemory:
403+
type: string
404+
pods:
405+
type: string
406+
requestsCpu:
407+
type: string
408+
requestsMemory:
409+
type: string
410+
required:
411+
- limitsCpu
412+
- limitsMemory
413+
- pods
414+
- requestsCpu
415+
- requestsMemory
416+
type: object
369417
runtimeClassName:
370418
description: |-
371419
RuntimeClassName is the RuntimeClass applied to agent pods (e.g. "gvisor")

config/crd/bases/paperclip.inc_instances.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,54 @@ spec:
360360
letting multiple instances share a cluster without namespace collisions.
361361
Maps to PAPERCLIP_K8S_NAMESPACE_PREFIX.
362362
type: string
363+
perTenantLimitRange:
364+
description: |-
365+
PerTenantLimitRange stamps a LimitRange on each per-tenant namespace (per-container
366+
default + max ceiling). Absent = no LimitRange. Maps to PAPERCLIP_K8S_LIMITRANGE_* env.
367+
properties:
368+
defaultCpu:
369+
type: string
370+
defaultMemory:
371+
type: string
372+
defaultRequestCpu:
373+
type: string
374+
defaultRequestMemory:
375+
type: string
376+
maxCpu:
377+
type: string
378+
maxMemory:
379+
type: string
380+
required:
381+
- defaultCpu
382+
- defaultMemory
383+
- defaultRequestCpu
384+
- defaultRequestMemory
385+
- maxCpu
386+
- maxMemory
387+
type: object
388+
perTenantQuota:
389+
description: |-
390+
PerTenantQuota stamps a ResourceQuota on each per-tenant namespace, bounding total
391+
CPU/memory/pods so one tenant cannot starve the shared sandbox pool. Absent = no quota
392+
(today's behavior). Maps to PAPERCLIP_K8S_QUOTA_* env consumed by the sandbox plugin.
393+
properties:
394+
limitsCpu:
395+
type: string
396+
limitsMemory:
397+
type: string
398+
pods:
399+
type: string
400+
requestsCpu:
401+
type: string
402+
requestsMemory:
403+
type: string
404+
required:
405+
- limitsCpu
406+
- limitsMemory
407+
- pods
408+
- requestsCpu
409+
- requestsMemory
410+
type: object
363411
runtimeClassName:
364412
description: |-
365413
RuntimeClassName is the RuntimeClass applied to agent pods (e.g. "gvisor")

internal/resources/resources_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,3 +1686,51 @@ func TestBuildAdapterRegistryEnvVarOmittedWhenEmpty(t *testing.T) {
16861686
t.Fatal("PAPERCLIP_ADAPTERS should not be set when registry is empty")
16871687
}
16881688
}
1689+
1690+
func TestQuotaEnvVars(t *testing.T) {
1691+
inst := &paperclipv1alpha1.Instance{
1692+
ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: "paperclip-app"},
1693+
Spec: paperclipv1alpha1.InstanceSpec{
1694+
Adapters: paperclipv1alpha1.AdaptersSpec{
1695+
Execution: &paperclipv1alpha1.ExecutionSpec{
1696+
Mode: "kubernetes",
1697+
Kubernetes: &paperclipv1alpha1.K8sExecutionSpec{
1698+
PerTenantQuota: &paperclipv1alpha1.TenantResourceQuota{
1699+
Pods: "20", RequestsCPU: "10", RequestsMemory: "20Gi", LimitsCPU: "20", LimitsMemory: "40Gi",
1700+
},
1701+
PerTenantLimitRange: &paperclipv1alpha1.TenantLimitRange{
1702+
DefaultCPU: "1", DefaultMemory: "2Gi", DefaultRequestCPU: "250m", DefaultRequestMem: "512Mi", MaxCPU: "4", MaxMemory: "8Gi",
1703+
},
1704+
},
1705+
},
1706+
},
1707+
},
1708+
}
1709+
got := map[string]string{}
1710+
for _, e := range buildExecutionEnvVars(inst) {
1711+
got[e.Name] = e.Value
1712+
}
1713+
if got["PAPERCLIP_K8S_QUOTA_PODS"] != "20" {
1714+
t.Errorf("PODS=%q want 20", got["PAPERCLIP_K8S_QUOTA_PODS"])
1715+
}
1716+
if got["PAPERCLIP_K8S_QUOTA_REQUESTS_CPU"] != "10" {
1717+
t.Errorf("REQ_CPU=%q want 10", got["PAPERCLIP_K8S_QUOTA_REQUESTS_CPU"])
1718+
}
1719+
if got["PAPERCLIP_K8S_LIMITRANGE_MAX_CPU"] != "4" {
1720+
t.Errorf("MAX_CPU=%q want 4", got["PAPERCLIP_K8S_LIMITRANGE_MAX_CPU"])
1721+
}
1722+
}
1723+
1724+
func TestQuotaEnvVarsAbsentWhenUnset(t *testing.T) {
1725+
inst := &paperclipv1alpha1.Instance{
1726+
ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: "paperclip-app"},
1727+
Spec: paperclipv1alpha1.InstanceSpec{
1728+
Adapters: paperclipv1alpha1.AdaptersSpec{Execution: &paperclipv1alpha1.ExecutionSpec{Mode: "kubernetes"}},
1729+
},
1730+
}
1731+
for _, e := range buildExecutionEnvVars(inst) {
1732+
if strings.HasPrefix(e.Name, "PAPERCLIP_K8S_QUOTA_") || strings.HasPrefix(e.Name, "PAPERCLIP_K8S_LIMITRANGE_") {
1733+
t.Errorf("unexpected quota env when unset: %s", e.Name)
1734+
}
1735+
}
1736+
}

internal/resources/statefulset.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,25 @@ func buildExecutionEnvVars(instance *paperclipv1alpha1.Instance) []corev1.EnvVar
656656
if k.NamespacePrefix != "" {
657657
vars = append(vars, corev1.EnvVar{Name: "PAPERCLIP_K8S_NAMESPACE_PREFIX", Value: k.NamespacePrefix})
658658
}
659+
if q := k.PerTenantQuota; q != nil {
660+
vars = append(vars,
661+
corev1.EnvVar{Name: "PAPERCLIP_K8S_QUOTA_PODS", Value: q.Pods},
662+
corev1.EnvVar{Name: "PAPERCLIP_K8S_QUOTA_REQUESTS_CPU", Value: q.RequestsCPU},
663+
corev1.EnvVar{Name: "PAPERCLIP_K8S_QUOTA_REQUESTS_MEMORY", Value: q.RequestsMemory},
664+
corev1.EnvVar{Name: "PAPERCLIP_K8S_QUOTA_LIMITS_CPU", Value: q.LimitsCPU},
665+
corev1.EnvVar{Name: "PAPERCLIP_K8S_QUOTA_LIMITS_MEMORY", Value: q.LimitsMemory},
666+
)
667+
}
668+
if lr := k.PerTenantLimitRange; lr != nil {
669+
vars = append(vars,
670+
corev1.EnvVar{Name: "PAPERCLIP_K8S_LIMITRANGE_DEFAULT_CPU", Value: lr.DefaultCPU},
671+
corev1.EnvVar{Name: "PAPERCLIP_K8S_LIMITRANGE_DEFAULT_MEMORY", Value: lr.DefaultMemory},
672+
corev1.EnvVar{Name: "PAPERCLIP_K8S_LIMITRANGE_DEFAULT_REQUEST_CPU", Value: lr.DefaultRequestCPU},
673+
corev1.EnvVar{Name: "PAPERCLIP_K8S_LIMITRANGE_DEFAULT_REQUEST_MEMORY", Value: lr.DefaultRequestMem},
674+
corev1.EnvVar{Name: "PAPERCLIP_K8S_LIMITRANGE_MAX_CPU", Value: lr.MaxCPU},
675+
corev1.EnvVar{Name: "PAPERCLIP_K8S_LIMITRANGE_MAX_MEMORY", Value: lr.MaxMemory},
676+
)
677+
}
659678

660679
return vars
661680
}

0 commit comments

Comments
 (0)