Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -187,3 +187,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