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
21 changes: 20 additions & 1 deletion docs/docs/operator/building-blocks/entities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public class EntitySpec
- `[RangeMinimum]` and `[RangeMaximum]`: Defines numeric value ranges
- `[MultipleOf]`: Specifies that a number must be a multiple of a given value
- `[Items]`: Defines minimum and maximum items for arrays
- `[ValidationRule]`: Defines custom validation rules using CEL expressions. Can be applied to properties and to class types — in both cases it emits `x-kubernetes-validations` on the corresponding schema node. When applied to both a class and a property of that type, the rules are merged (class rules first, then property rules)
- `[ValidationRule]`: Defines custom validation rules using CEL expressions. Can be applied to properties and to class types — in both cases it emits `x-kubernetes-validations` on the corresponding schema node. When applied to both a class and a property of that type, the rules are merged (class rules first, then property rules). Class-level rules are also collected across the **class inheritance chain**: a rule placed on a base class is inherited by every derived entity or nested type, and all rules found along the chain are merged onto the schema node

### Documentation Attributes

Expand Down Expand Up @@ -196,6 +196,25 @@ public class V1DemoEntity : CustomKubernetesEntity<V1DemoEntity.V1DemoEntitySpec
}
```

## Attribute Inheritance

CRD-shaping attributes are collected across the **class inheritance chain**. An attribute placed on a base class is picked up by every derived entity, so shared CRD configuration can live on a common base type.

You can also create **reusable, named attributes** by subclassing an existing attribute and forwarding the values to its base constructor:

```csharp
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ReadyPrinterColumnAttribute : GenericAdditionalPrinterColumnAttribute
{
public ReadyPrinterColumnAttribute()
: base(".status.conditions[?(@.type==\"Ready\")].status", "Ready", "string") { }
}

[KubernetesEntity(Group = "demo.kubeops.dev", ApiVersion = "v1", Kind = "DemoEntity")]
[ReadyPrinterColumn]
public class V1DemoEntity : CustomKubernetesEntity { }
```

## 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.
Expand Down
2 changes: 1 addition & 1 deletion src/KubeOps.Cli/Commands/Management/Install.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ internal static async Task<int> Handler(IAnsiConsole console, IKubernetes client
};

console.WriteLine($"Install CRDs from {file.Name}.");
var crds = parser.Transpile(parser.GetEntities()).ToList();
var crds = parser.Transpile(parser.GetEntities(), parser.GetInheritedAttributeResolver()).ToList();
if (crds.Count == 0)
{
console.WriteLine("No CRDs found. Exiting.");
Expand Down
2 changes: 1 addition & 1 deletion src/KubeOps.Cli/Commands/Management/Uninstall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ internal static async Task<int> Handler(IAnsiConsole console, IKubernetes client
};

console.WriteLine($"Uninstall CRDs from {file.Name}.");
var crds = parser.Transpile(parser.GetEntities()).ToList();
var crds = parser.Transpile(parser.GetEntities(), parser.GetInheritedAttributeResolver()).ToList();
if (crds.Count == 0)
{
console.WriteLine("No CRDs found. Exiting.");
Expand Down
14 changes: 6 additions & 8 deletions src/KubeOps.Cli/Generators/CrdGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,35 @@

using System.Reflection;

using k8s.Models;

using KubeOps.Cli.Output;
using KubeOps.Cli.Transpilation;
using KubeOps.Transpiler;

namespace KubeOps.Cli.Generators;

internal class CrdGenerator(MetadataLoadContext parser, byte[] caBundle,
internal sealed class CrdGenerator(MetadataLoadContext parser, byte[] caBundle,
OutputFormat outputFormat) : IConfigGenerator
{
public void Generate(ResultOutput output)
{
var crds = parser.Transpile(parser.GetEntities()).ToList();
var crds = parser.Transpile(parser.GetEntities(), parser.GetInheritedAttributeResolver()).ToList();
var conversionWebhooks = parser.GetConvertedEntities().ToList();

foreach (var crd in crds)
{
if (conversionWebhooks
.Find(wh => crd.Spec.Group == wh.Group && crd.Spec.Names.Kind == wh.Kind) is not null)
{
crd.Spec.Conversion = new V1CustomResourceConversion
crd.Spec.Conversion = new()
{
Strategy = "Webhook",
Webhook = new V1WebhookConversion
Webhook = new()
{
ConversionReviewVersions = new[] { "v1" },
ClientConfig = new Apiextensionsv1WebhookClientConfig
ClientConfig = new()
{
CaBundle = caBundle,
Service = new Apiextensionsv1ServiceReference
Service = new()
{
Path = $"/convert/{crd.Spec.Group}/{crd.Spec.Names.Plural}",
Name = "service",
Expand Down
71 changes: 49 additions & 22 deletions src/KubeOps.Cli/Transpilation/AssemblyLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;

Expand Down Expand Up @@ -33,11 +34,27 @@ namespace KubeOps.Cli.Transpilation;
Justification = "It is the CLI that uses the libraries.")]
internal static partial class AssemblyLoader
{
private static readonly ConditionalWeakTable<MetadataLoadContext, IInheritedAttributeResolver>
InheritedAttributeResolvers = new();

static AssemblyLoader()
{
MSBuildLocator.RegisterDefaults();
}

/// <summary>
/// Returns the Roslyn-based resolver associated with a context loaded by this loader, used to
/// recover inherited attribute property values without executing user code. Falls back to
/// reflection-based resolution for contexts not created here.
/// </summary>
/// <param name="context">The context previously created by <see cref="ForProject"/> or <see cref="ForSolution"/>.</param>
/// <returns>The resolver for inherited attribute property values.</returns>
public static IInheritedAttributeResolver GetInheritedAttributeResolver(
this MetadataLoadContext context)
=> InheritedAttributeResolvers.TryGetValue(context, out var resolver)
? resolver
: ReflectionInheritedAttributeResolver.Default;

public static Task<MetadataLoadContext> ForProject(
IAnsiConsole console,
FileInfo projectFile)
Expand Down Expand Up @@ -74,24 +91,27 @@ public static Task<MetadataLoadContext> ForProject(
.Concat(new[] { typeof(object).Assembly.Location })));
mlc.LoadFromByteArray(assemblyStream.ToArray());

InheritedAttributeResolvers.AddOrUpdate(
mlc, new RoslynInheritedAttributeResolver(new[] { compilation }));

return mlc;
});

public static Task<MetadataLoadContext> ForSolution(
IAnsiConsole console,
FileInfo slnFile,
Regex? projectFilter = null,
string? tfm = null)
string? targetFramework = null)
=> console.Status().StartAsync($"Compiling {slnFile.Name}...", async _ =>
{
projectFilter ??= DefaultRegex();
tfm ??= "latest";
targetFramework ??= "latest";

console.MarkupLineInterpolated($"Compile solution [aqua]{slnFile.FullName}[/].");
#pragma warning disable RCS1097
console.MarkupLineInterpolated($"[grey]With project filter:[/] {projectFilter.ToString()}");
#pragma warning restore RCS1097
console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {tfm}");
console.MarkupLineInterpolated($"[grey]With Target Platform:[/] {targetFramework}");

using var workspace = MSBuildWorkspace.Create();
workspace.SkipUnrecognizedProjects = true;
Expand All @@ -103,45 +123,47 @@ public static Task<MetadataLoadContext> ForSolution(
var assemblies = await Task.WhenAll(solution.Projects
.Select(p =>
{
var name = TfmComparer.TfmRegex().Replace(p.Name, string.Empty);
var tfm = TfmComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value;
return (name, tfm, project: p);
var name = TargetFrameworkComparer.TfmRegex().Replace(p.Name, string.Empty);
var ltfm = TargetFrameworkComparer.TfmRegex().Match(p.Name).Groups["tfm"].Value;
return (Name: name, TargetFramework: ltfm, Project: p);
})
.Where(p => projectFilter.IsMatch(p.name))
.Where(p => tfm == "latest" || p.tfm.Length == 0 || p.tfm == tfm)
.OrderByDescending(p => p.tfm, new TfmComparer())
.GroupBy(p => p.name)
.Where(p =>
projectFilter.IsMatch(p.Name)
&& (targetFramework == "latest" || p.TargetFramework.Length == 0 || p.TargetFramework == targetFramework))
.OrderByDescending(p => p.TargetFramework, new TargetFrameworkComparer())
.GroupBy(p => p.Name)
.Select(p => p.FirstOrDefault())
.Where(p => p != default)
.Select(async p =>
{
console.MarkupLineInterpolated(
p.tfm.Length > 0
? (FormattableString)$"Load compilation context for [aqua]{p.name}[/] [grey]{p.tfm}[/]."
: (FormattableString)$"Load compilation context for [aqua]{p.name}[/].");
p.TargetFramework.Length > 0
? (FormattableString)$"Load compilation context for [aqua]{p.Name}[/] [grey]{p.TargetFramework}[/]."
: (FormattableString)$"Load compilation context for [aqua]{p.Name}[/].");

var compilation = await p.project.GetCompilationAsync();
console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.name}.[/]");
var compilation = await p.Project.GetCompilationAsync();
console.MarkupLineInterpolated($"[green]Compilation context loaded for {p.Name}.[/]");
if (compilation is null)
{
throw new AggregateException("Compilation could not be found.");
}

await using var assemblyStream = new MemoryStream();
console.MarkupLineInterpolated(
p.tfm.Length > 0
? (FormattableString)$"Start compilation for [aqua]{p.name}[/] [grey]{p.tfm}[/]."
: (FormattableString)$"Start compilation for [aqua]{p.name}[/].");
p.TargetFramework.Length > 0
? (FormattableString)$"Start compilation for [aqua]{p.Name}[/] [grey]{p.TargetFramework}[/]."
: (FormattableString)$"Start compilation for [aqua]{p.Name}[/].");
switch (compilation.Emit(assemblyStream))
{
case { Success: false, Diagnostics: var diag }:
throw new AggregateException(
$"Compilation failed: {diag.Aggregate(new StringBuilder(), (sb, d) => sb.AppendLine(d.ToString()))}");
}

console.MarkupLineInterpolated($"[green]Compilation successful for {p.name}.[/]");
console.MarkupLineInterpolated($"[green]Compilation successful for {p.Name}.[/]");
return (Assembly: assemblyStream.ToArray(),
Refs: p.project.MetadataReferences.Select(m => m.Display ?? string.Empty));
Refs: p.Project.MetadataReferences.Select(m => m.Display ?? string.Empty),
Compilation: compilation);
}));

console.WriteLine();
Expand All @@ -153,6 +175,10 @@ public static Task<MetadataLoadContext> ForSolution(
mlc.LoadFromByteArray(assembly.Assembly);
}

InheritedAttributeResolvers.AddOrUpdate(
mlc,
new RoslynInheritedAttributeResolver(assemblies.Select(a => a.Compilation).ToList()));

return mlc;
});

Expand All @@ -164,7 +190,8 @@ public static IEnumerable<Type> GetEntities(this MetadataLoadContext context) =>
.Select(e => e.t);

public static IEnumerable<CustomAttributeData> GetRbacAttributes(this MetadataLoadContext context) => context
.GetTypesToInspect().SelectMany(t => t.GetCustomAttributesData<GenericRbacAttribute>().Concat(t.GetCustomAttributesData<EntityRbacAttribute>()));
.GetTypesToInspect().SelectMany(t => t.GetInheritedCustomAttributesData<GenericRbacAttribute>()
.Concat(t.GetInheritedCustomAttributesData<EntityRbacAttribute>()));

public static IEnumerable<ValidationWebhook> GetValidatedEntities(this MetadataLoadContext context) => context
.GetTypesToInspect()
Expand All @@ -190,7 +217,7 @@ public static IEnumerable<EntityMetadata> GetConvertedEntities(this MetadataLoad
private static IEnumerable<TypeInfo> GetTypesToInspect(this MetadataLoadContext context) => context
.GetAssemblies()
.SelectMany(a => a.DefinedTypes)
.Where(t => !t.IsInterface && !t.IsAbstract && !t.IsGenericType)
.Where(t => t is { IsInterface: false, IsAbstract: false, IsGenericType: false })
.OrderBy(t => t.FullName, StringComparer.Ordinal);

[GeneratedRegex(".*")]
Expand Down
114 changes: 114 additions & 0 deletions src/KubeOps.Cli/Transpilation/RoslynInheritedAttributeResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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 System.Collections.ObjectModel;

using KubeOps.Transpiler;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace KubeOps.Cli.Transpilation;

/// <summary>
/// Resolves inherited attribute values via the Roslyn semantic model. The CLI must not load or
/// execute the user's assembly, so reflection-based resolution is not an option here. Instead the
/// already-built <see cref="Compilation"/> is queried: the parameterless constructor's
/// <c>: base(...)</c> initializer is located in source, its arguments are read as compile-time
/// constants, and each constant is mapped to the base-constructor parameter — and therefore the
/// property — it initializes. No user code is executed and no IL is parsed.
/// </summary>
internal sealed class RoslynInheritedAttributeResolver(IReadOnlyList<Compilation> compilations)
: IInheritedAttributeResolver
{
public bool TryResolve(Type attributeType, out IReadOnlyDictionary<string, object?> propertyValues)
{
propertyValues = ReadOnlyDictionary<string, object?>.Empty;

if (attributeType.FullName is not { } fullName)
{
return false;
}

foreach (var compilation in compilations)
{
if (compilation.GetTypeByMetadataName(fullName) is not { } symbol)
{
continue;
}

var ctor = symbol.InstanceConstructors.FirstOrDefault(c => c.Parameters.IsEmpty);
if (ctor?.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax()
is not ConstructorDeclarationSyntax { Initializer.ArgumentList.Arguments.Count: > 0 } decl)
{
continue;
}

var model = compilation.GetSemanticModel(decl.SyntaxTree);
if (model.GetSymbolInfo(decl.Initializer!).Symbol is not IMethodSymbol baseCtor)
{
continue;
}

var values = ResolveArguments(model, decl.Initializer!.ArgumentList.Arguments, baseCtor, symbol);
if (values.Count > 0)
{
propertyValues = values;
return true;
}
}

return false;
}

private static Dictionary<string, object?> ResolveArguments(
SemanticModel model,
IReadOnlyList<ArgumentSyntax> arguments,
IMethodSymbol baseCtor,
INamedTypeSymbol attributeSymbol)
{
var values = new Dictionary<string, object?>(StringComparer.Ordinal);
for (var i = 0; i < arguments.Count; i++)
{
var argument = arguments[i];
var constant = model.GetConstantValue(argument.Expression);
if (!constant.HasValue)
{
continue;
}

var parameterName = argument.NameColon?.Name.Identifier.ValueText
?? (i < baseCtor.Parameters.Length ? baseCtor.Parameters[i].Name : null);
if (FindProperty(attributeSymbol, parameterName) is not { } property)
{
continue;
}

values[property.Name] = constant.Value;
}

return values;
}

private static IPropertySymbol? FindProperty(INamedTypeSymbol attributeSymbol, string? parameterName)
{
if (parameterName is null)
{
return null;
}

for (INamedTypeSymbol? current = attributeSymbol; current is not null; current = current.BaseType)
{
var match = current.GetMembers()
.OfType<IPropertySymbol>()
.FirstOrDefault(p => string.Equals(p.Name, parameterName, StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
return match;
}
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace KubeOps.Cli.Transpilation;
/// <summary>
/// Tfm Comparer.
/// </summary>
internal sealed partial class TfmComparer : IComparer<string>
internal sealed partial class TargetFrameworkComparer : IComparer<string>
{
[GeneratedRegex(
"[(]?(?<tfm>(?<n>(netcoreapp|net|netstandard){1})(?<major>[0-9]+)[.](?<minor>[0-9]+))[)]?",
Expand Down
Loading
Loading