Skip to content
74 changes: 74 additions & 0 deletions docs/docs/operator/building-blocks/entities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,77 @@ public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec
}
}
```

## Scale Subresource

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.

The attribute accepts two required parameters and one optional parameter — all JSON paths into the resource object:

| Parameter | Required | Description |
|-----------|----------|-------------|
| `specReplicasPath` | Yes | JSON path to desired replicas in spec, e.g. `.spec.replicas` |
| `statusReplicasPath` | Yes | JSON path to observed replicas in status, e.g. `.status.replicas` |
| `labelSelectorPath` | No | JSON path to the serialized label selector, e.g. `.status.selector` |

### Example — scale without status subresource

When the entity has no `Status` property only the `scale` subresource is emitted. The `status` subresource is **not** added automatically.

```csharp
[KubernetesEntity(Group = "demo.kubeops.dev", ApiVersion = "v1", Kind = "DemoEntity")]
[ScaleSubresource(".spec.replicas", ".status.replicas")]
public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec>
{
public class V1DemoEntitySpec
{
public int Replicas { get; set; } = 1;
}
}
```

Generated CRD:

```yaml
subresources:
scale:
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas
```

### Example — scale and status subresources together

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.

```csharp
[KubernetesEntity(Group = "demo.kubeops.dev", ApiVersion = "v1", Kind = "DemoEntity")]
[ScaleSubresource(".spec.replicas", ".status.replicas", ".status.selector")]
public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec, V1DemoEntity.V1DemoEntityStatus>
{
public class V1DemoEntitySpec
{
public int Replicas { get; set; } = 1;
}

public class V1DemoEntityStatus
{
public int Replicas { get; set; }
public required string Selector { get; init; }
}
}
```

Generated CRD:

```yaml
subresources:
status: {}
scale:
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas
labelSelectorPath: .status.selector
```

:::note
`[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.
:::
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

namespace KubeOps.Abstractions.Entities.Attributes;

/// <summary>
/// Enables the scale subresource on a Custom Resource Definition, allowing
/// Kubernetes HorizontalPodAutoscalers (HPAs) to scale the resource.
/// </summary>
/// <param name="specReplicasPath">JSON path to desired replicas in spec, e.g. <c>.spec.replicas</c>.</param>
/// <param name="statusReplicasPath">JSON path to observed replicas in status, e.g. <c>.status.replicas</c>.</param>
/// <param name="labelSelectorPath">Optional JSON path to the label selector, e.g. <c>.status.selector</c>.</param>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class ScaleSubresourceAttribute(
string specReplicasPath,
string statusReplicasPath,
string? labelSelectorPath = null) : Attribute
{
public string SpecReplicasPath => specReplicasPath;

public string StatusReplicasPath => statusReplicasPath;

public string? LabelSelectorPath => labelSelectorPath;
}
18 changes: 13 additions & 5 deletions src/KubeOps.Transpiler/Crds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,22 @@ public static V1CustomResourceDefinition Transpile(this MetadataLoadContext cont
}

var version = new V1CustomResourceDefinitionVersion { Name = meta.Version, Served = true, Storage = true };
if
(type.GetProperty("Status") != null
|| type.GetProperty("status") != null)
var hasStatus = type.GetProperty("status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) != null;
var scaleAttr = type.GetCustomAttributeData<ScaleSubresourceAttribute>();

if (hasStatus || scaleAttr != null)
{
version.Subresources = new()
{
Scale = null,
Status = new(),
Status = hasStatus ? new() : null,
Scale = scaleAttr != null
? new V1CustomResourceSubresourceScale
{
SpecReplicasPath = scaleAttr.GetCustomAttributeCtorArg<string>(context, 0)!,
StatusReplicasPath = scaleAttr.GetCustomAttributeCtorArg<string>(context, 1)!,
LabelSelectorPath = scaleAttr.GetCustomAttributeCtorArg<string>(context, 2),
}
: null,
};
}

Expand Down
111 changes: 111 additions & 0 deletions test/KubeOps.Transpiler.Test/Crds.Mlc.Scale.Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using FluentAssertions;

using k8s.Models;

using KubeOps.Abstractions.Entities;
using KubeOps.Abstractions.Entities.Attributes;

namespace KubeOps.Transpiler.Test;

public sealed partial class CrdsMlcTest
{
[Fact]
public void Should_Not_Add_Scale_SubResource_If_Absent()
{
var crd = _mlc.Transpile(typeof(EntityWithStatus));

var subresources = crd.Spec.Versions[0].Subresources;
subresources.Should().NotBeNull();
subresources.Status.Should().NotBeNull();
subresources.Scale.Should().BeNull();
}

[Fact]
public void Should_Add_Scale_SubResource_With_Required_Paths()
{
var crd = _mlc.Transpile(typeof(EntityWithScaleSubresource));

var subresources = crd.Spec.Versions[0].Subresources;
subresources.Should().NotBeNull();
subresources.Scale.Should().NotBeNull();
subresources.Scale.SpecReplicasPath.Should().Be(".spec.replicas");
subresources.Scale.StatusReplicasPath.Should().Be(".status.replicas");
subresources.Scale.LabelSelectorPath.Should().BeNull();
}

[Fact]
public void Should_Add_Scale_Without_Status_SubResource()
{
var crd = _mlc.Transpile(typeof(EntityWithScaleSubresource));

var subresources = crd.Spec.Versions[0].Subresources;
subresources.Should().NotBeNull();
subresources.Status.Should().BeNull();
}

[Fact]
public void Should_Add_Scale_SubResource_With_Label_Selector_Path()
{
var crd = _mlc.Transpile(typeof(EntityWithScaleAndSelector));

var subresources = crd.Spec.Versions[0].Subresources;
subresources.Should().NotBeNull();
subresources.Scale.Should().NotBeNull();
subresources.Scale.SpecReplicasPath.Should().Be(".spec.replicas");
subresources.Scale.StatusReplicasPath.Should().Be(".status.replicas");
subresources.Scale.LabelSelectorPath.Should().Be(".status.selector");
}

[Fact]
public void Should_Add_Both_Scale_And_Status_SubResources()
{
var crd = _mlc.Transpile(typeof(EntityWithScaleAndStatus));

var subresources = crd.Spec.Versions[0].Subresources;
subresources.Should().NotBeNull();
subresources.Scale.Should().NotBeNull();
subresources.Scale.SpecReplicasPath.Should().Be(".spec.replicas");
subresources.Scale.StatusReplicasPath.Should().Be(".status.replicas");
subresources.Status.Should().NotBeNull();
}

[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
[ScaleSubresource(".spec.replicas", ".status.replicas")]
public sealed class EntityWithScaleSubresource : CustomKubernetesEntity<EntityWithScaleSubresource.EntitySpec>
{
public sealed class EntitySpec
{
public int Replicas { get; set; }
}
}

[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
[ScaleSubresource(".spec.replicas", ".status.replicas", ".status.selector")]
public sealed class EntityWithScaleAndSelector : CustomKubernetesEntity<EntityWithScaleAndSelector.EntitySpec>
{
public sealed class EntitySpec
{
public int Replicas { get; set; }
}
}

[KubernetesEntity(Group = "testing.dev", ApiVersion = "v1", Kind = "TestEntity")]
[ScaleSubresource(".spec.replicas", ".status.replicas")]
public sealed class EntityWithScaleAndStatus
: CustomKubernetesEntity<EntityWithScaleAndStatus.EntitySpec, EntityWithScaleAndStatus.EntityStatus>
{
public sealed class EntitySpec
{
public int Replicas { get; set; }
}

public sealed class EntityStatus
{
public int Replicas { get; set; }
}
}
}
Loading
Loading