Skip to content

Commit ae86735

Browse files
authored
feat(CRDS): add ScaleSubresourceAttribute for enabling HPA scaling (#1095)
- Introduced `ScaleSubresourceAttribute` to facilitate HPA scaling by enabling scale subresources on CRDs. - Updated transpiler logic and tests to ensure compatibility with scale subresources, including verification for replicas and label selectors. fixes #924
1 parent 47964b8 commit ae86735

5 files changed

Lines changed: 309 additions & 91 deletions

File tree

docs/docs/operator/building-blocks/entities.mdx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,77 @@ public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec
187187
}
188188
}
189189
```
190+
191+
## Scale Subresource
192+
193+
The `[ScaleSubresource]` attribute enables the Kubernetes [scale subresource](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource) on a CRD. This allows [HorizontalPodAutoscalers (HPAs)](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/) to manage the replica count of your custom resource.
194+
195+
The attribute accepts two required parameters and one optional parameter — all JSON paths into the resource object:
196+
197+
| Parameter | Required | Description |
198+
|-----------|----------|-------------|
199+
| `specReplicasPath` | Yes | JSON path to desired replicas in spec, e.g. `.spec.replicas` |
200+
| `statusReplicasPath` | Yes | JSON path to observed replicas in status, e.g. `.status.replicas` |
201+
| `labelSelectorPath` | No | JSON path to the serialized label selector, e.g. `.status.selector` |
202+
203+
### Example — scale without status subresource
204+
205+
When the entity has no `Status` property only the `scale` subresource is emitted. The `status` subresource is **not** added automatically.
206+
207+
```csharp
208+
[KubernetesEntity(Group = "demo.kubeops.dev", ApiVersion = "v1", Kind = "DemoEntity")]
209+
[ScaleSubresource(".spec.replicas", ".status.replicas")]
210+
public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec>
211+
{
212+
public class V1DemoEntitySpec
213+
{
214+
public int Replicas { get; set; } = 1;
215+
}
216+
}
217+
```
218+
219+
Generated CRD:
220+
221+
```yaml
222+
subresources:
223+
scale:
224+
specReplicasPath: .spec.replicas
225+
statusReplicasPath: .status.replicas
226+
```
227+
228+
### Example — scale and status subresources together
229+
230+
When the entity inherits from `CustomKubernetesEntity<TSpec, TStatus>`, both `status` and `scale` subresources are emitted. `labelSelectorPath` is optional and maps the serialized label selector that HPAs use for targeted scaling.
231+
232+
```csharp
233+
[KubernetesEntity(Group = "demo.kubeops.dev", ApiVersion = "v1", Kind = "DemoEntity")]
234+
[ScaleSubresource(".spec.replicas", ".status.replicas", ".status.selector")]
235+
public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec, V1DemoEntity.V1DemoEntityStatus>
236+
{
237+
public class V1DemoEntitySpec
238+
{
239+
public int Replicas { get; set; } = 1;
240+
}
241+
242+
public class V1DemoEntityStatus
243+
{
244+
public int Replicas { get; set; }
245+
public required string Selector { get; init; }
246+
}
247+
}
248+
```
249+
250+
Generated CRD:
251+
252+
```yaml
253+
subresources:
254+
status: {}
255+
scale:
256+
specReplicasPath: .spec.replicas
257+
statusReplicasPath: .status.replicas
258+
labelSelectorPath: .status.selector
259+
```
260+
261+
:::note
262+
`[ScaleSubresource]` and the status subresource are controlled independently. A `Status` property activates `status: {}` regardless of `[ScaleSubresource]`, and `[ScaleSubresource]` adds `scale:` regardless of whether a `Status` property exists.
263+
:::
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace KubeOps.Abstractions.Entities.Attributes;
6+
7+
/// <summary>
8+
/// Enables the scale subresource on a Custom Resource Definition, allowing
9+
/// Kubernetes HorizontalPodAutoscalers (HPAs) to scale the resource.
10+
/// </summary>
11+
/// <param name="specReplicasPath">JSON path to desired replicas in spec, e.g. <c>.spec.replicas</c>.</param>
12+
/// <param name="statusReplicasPath">JSON path to observed replicas in status, e.g. <c>.status.replicas</c>.</param>
13+
/// <param name="labelSelectorPath">Optional JSON path to the label selector, e.g. <c>.status.selector</c>.</param>
14+
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
15+
public sealed class ScaleSubresourceAttribute(
16+
string specReplicasPath,
17+
string statusReplicasPath,
18+
string? labelSelectorPath = null) : Attribute
19+
{
20+
public string SpecReplicasPath => specReplicasPath;
21+
22+
public string StatusReplicasPath => statusReplicasPath;
23+
24+
public string? LabelSelectorPath => labelSelectorPath;
25+
}

src/KubeOps.Transpiler/Crds.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,22 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont
6868
}
6969

7070
var version = new V1CustomResourceDefinitionVersion { Name = meta.Version, Served = true, Storage = true };
71-
if
72-
(type.GetProperty("Status") != null
73-
|| type.GetProperty("status") != null)
71+
var hasStatus = type.GetProperty("status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) != null;
72+
var scaleAttr = type.GetCustomAttributeData<ScaleSubresourceAttribute>();
73+
74+
if (hasStatus || scaleAttr != null)
7475
{
7576
version.Subresources = new()
7677
{
77-
Scale = null,
78-
Status = new(),
78+
Status = hasStatus ? new() : null,
79+
Scale = scaleAttr != null
80+
? new V1CustomResourceSubresourceScale
81+
{
82+
SpecReplicasPath = scaleAttr.GetCustomAttributeCtorArg<string>(context, 0)!,
83+
StatusReplicasPath = scaleAttr.GetCustomAttributeCtorArg<string>(context, 1)!,
84+
LabelSelectorPath = scaleAttr.GetCustomAttributeCtorArg<string>(context, 2),
85+
}
86+
: null,
7987
};
8088
}
8189

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using FluentAssertions;
6+
7+
using k8s.Models;
8+
9+
using KubeOps.Abstractions.Entities;
10+
using KubeOps.Abstractions.Entities.Attributes;
11+
12+
namespace KubeOps.Transpiler.Test;
13+
14+
public sealed partial class CrdsMlcTest
15+
{
16+
[Fact]
17+
public void Should_Not_Add_Scale_SubResource_If_Absent()
18+
{
19+
var crd = _mlc.Transpile(typeof(EntityWithStatus));
20+
21+
var subresources = crd.Spec.Versions[0].Subresources;
22+
subresources.Should().NotBeNull();
23+
subresources.Status.Should().NotBeNull();
24+
subresources.Scale.Should().BeNull();
25+
}
26+
27+
[Fact]
28+
public void Should_Add_Scale_SubResource_With_Required_Paths()
29+
{
30+
var crd = _mlc.Transpile(typeof(EntityWithScaleSubresource));
31+
32+
var subresources = crd.Spec.Versions[0].Subresources;
33+
subresources.Should().NotBeNull();
34+
subresources.Scale.Should().NotBeNull();
35+
subresources.Scale.SpecReplicasPath.Should().Be(".spec.replicas");
36+
subresources.Scale.StatusReplicasPath.Should().Be(".status.replicas");
37+
subresources.Scale.LabelSelectorPath.Should().BeNull();
38+
}
39+
40+
[Fact]
41+
public void Should_Add_Scale_Without_Status_SubResource()
42+
{
43+
var crd = _mlc.Transpile(typeof(EntityWithScaleSubresource));
44+
45+
var subresources = crd.Spec.Versions[0].Subresources;
46+
subresources.Should().NotBeNull();
47+
subresources.Status.Should().BeNull();
48+
}
49+
50+
[Fact]
51+
public void Should_Add_Scale_SubResource_With_Label_Selector_Path()
52+
{
53+
var crd = _mlc.Transpile(typeof(EntityWithScaleAndSelector));
54+
55+
var subresources = crd.Spec.Versions[0].Subresources;
56+
subresources.Should().NotBeNull();
57+
subresources.Scale.Should().NotBeNull();
58+
subresources.Scale.SpecReplicasPath.Should().Be(".spec.replicas");
59+
subresources.Scale.StatusReplicasPath.Should().Be(".status.replicas");
60+
subresources.Scale.LabelSelectorPath.Should().Be(".status.selector");
61+
}
62+
63+
[Fact]
64+
public void Should_Add_Both_Scale_And_Status_SubResources()
65+
{
66+
var crd = _mlc.Transpile(typeof(EntityWithScaleAndStatus));
67+
68+
var subresources = crd.Spec.Versions[0].Subresources;
69+
subresources.Should().NotBeNull();
70+
subresources.Scale.Should().NotBeNull();
71+
subresources.Scale.SpecReplicasPath.Should().Be(".spec.replicas");
72+
subresources.Scale.StatusReplicasPath.Should().Be(".status.replicas");
73+
subresources.Status.Should().NotBeNull();
74+
}
75+
76+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
77+
[ScaleSubresource(".spec.replicas", ".status.replicas")]
78+
public sealed class EntityWithScaleSubresource : CustomKubernetesEntity<EntityWithScaleSubresource.EntitySpec>
79+
{
80+
public sealed class EntitySpec
81+
{
82+
public int Replicas { get; set; }
83+
}
84+
}
85+
86+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
87+
[ScaleSubresource(".spec.replicas", ".status.replicas", ".status.selector")]
88+
public sealed class EntityWithScaleAndSelector : CustomKubernetesEntity<EntityWithScaleAndSelector.EntitySpec>
89+
{
90+
public sealed class EntitySpec
91+
{
92+
public int Replicas { get; set; }
93+
}
94+
}
95+
96+
[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
97+
[ScaleSubresource(".spec.replicas", ".status.replicas")]
98+
public sealed class EntityWithScaleAndStatus
99+
: CustomKubernetesEntity<EntityWithScaleAndStatus.EntitySpec, EntityWithScaleAndStatus.EntityStatus>
100+
{
101+
public sealed class EntitySpec
102+
{
103+
public int Replicas { get; set; }
104+
}
105+
106+
public sealed class EntityStatus
107+
{
108+
public int Replicas { get; set; }
109+
}
110+
}
111+
}

0 commit comments

Comments
 (0)