Skip to content
Open
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
79 changes: 79 additions & 0 deletions RackPeek.Domain/Helpers/ResourceTemplateSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Collections.Specialized;
using System.Text.Json;
using RackPeek.Domain.Persistence.Yaml;
using RackPeek.Domain.Resources;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace RackPeek.Domain.Helpers;

/// <summary>
/// Serializes a <see cref="Resource"/> to template-format YAML suitable for
/// contributing as a bundled hardware template. Instance-specific fields
/// (tags, labels, notes, runsOn) are stripped from the output.
/// </summary>
public static class ResourceTemplateSerializer
{
private static readonly HashSet<string> ExcludedKeys = new(StringComparer.OrdinalIgnoreCase)
{
"tags", "labels", "notes", "runsOn", "kind"
};

/// <summary>
/// Produces a template-format YAML string for the given resource.
/// The output has <c>kind</c> first, then <c>name</c>, followed by
/// type-specific hardware properties.
/// </summary>
/// <param name="resource">The resource to serialize.</param>
/// <param name="templateName">
/// Optional official hardware name to use in the template instead of
/// the resource's current <see cref="Resource.Name"/>.
/// </param>
public static string Serialize(Resource resource, string? templateName = null)
{
var concreteType = resource.GetType();
var json = JsonSerializer.Serialize(resource, concreteType);
var clone = (Resource)JsonSerializer.Deserialize(json, concreteType)!;
clone.Tags = [];
clone.Labels = new Dictionary<string, string>();
clone.Notes = null;
clone.RunsOn = [];

var kind = Resource.GetKind(clone);

var serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithTypeConverter(new NotesStringYamlConverter())
.ConfigureDefaultValuesHandling(
DefaultValuesHandling.OmitNull |
DefaultValuesHandling.OmitEmptyCollections
)
.Build();

var yaml = serializer.Serialize(clone);

var props = new DeserializerBuilder()
.Build()
.Deserialize<Dictionary<string, object?>>(yaml);

var map = new OrderedDictionary
{
["kind"] = kind,
["name"] = templateName ?? clone.Name
};

if (props is not null)
{
foreach (var (key, value) in props)
{
if (ExcludedKeys.Contains(key) ||
string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
continue;

map[key] = value;
}
}

return serializer.Serialize(map);
}
}
13 changes: 13 additions & 0 deletions RackPeek.Domain/Resources/Resource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ public static string GetKind<T>() where T : Resource
$"No kind mapping defined for type {typeof(T).Name}");
}

/// <summary>
/// Resolves the kind label for a resource instance at runtime.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the resource type has no kind mapping.</exception>
public static string GetKind(Resource resource)
{
if (TypeToKindMap.TryGetValue(resource.GetType(), out var kind))
return kind;

throw new InvalidOperationException(
$"No kind mapping defined for type {resource.GetType().Name}");
}

public static bool CanRunOn<T>(Resource parent) where T : Resource
{
var childKind = GetKind<T>().ToLowerInvariant();
Expand Down
2 changes: 2 additions & 0 deletions RackPeek.Domain/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using RackPeek.Domain.Resources.Hardware;
using RackPeek.Domain.Resources.Services;
using RackPeek.Domain.Resources.SystemResources;
using RackPeek.Domain.Templates;
using RackPeek.Domain.UseCases;
using RackPeek.Domain.UseCases.Cpus;
using RackPeek.Domain.UseCases.Drives;
Expand Down Expand Up @@ -49,6 +50,7 @@
this IServiceCollection services)
{
services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
services.AddScoped(typeof(IAddResourceFromTemplateUseCase<>), typeof(AddResourceFromTemplateUseCase<>));
services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));
services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>));
services.AddScoped(typeof(ICloneResourceUseCase<>), typeof(CloneResourceUseCase<>));
Expand Down Expand Up @@ -87,7 +89,7 @@
typeof(IUseCase).IsAssignableFrom(t)
);

foreach (var type in usecases) services.AddScoped(type);

Check warning on line 92 in RackPeek.Domain/ServiceCollectionExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

return services;
}
Expand Down
158 changes: 158 additions & 0 deletions RackPeek.Domain/Templates/BundledHardwareTemplateStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using RackPeek.Domain.Persistence.Yaml;
using RackPeek.Domain.Resources;
using RackPeek.Domain.Resources.AccessPoints;
using RackPeek.Domain.Resources.Firewalls;
using RackPeek.Domain.Resources.Routers;
using RackPeek.Domain.Resources.Servers;
using RackPeek.Domain.Resources.Switches;
using RackPeek.Domain.Resources.UpsUnits;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;

namespace RackPeek.Domain.Templates;

/// <summary>
/// Reads hardware templates from YAML files stored in a local directory tree.
/// Expected layout: <c>{basePath}/{kind}/*.yaml</c> where kind is the plural
/// lowercase form (e.g. <c>switches/</c>, <c>routers/</c>).
/// Templates are cached in memory after the first load.
/// </summary>
public sealed class BundledHardwareTemplateStore : IHardwareTemplateStore
{
private readonly string _basePath;
private readonly IDeserializer _deserializer;
private List<HardwareTemplate>? _cache;
private readonly SemaphoreSlim _loadLock = new(1, 1);

private static readonly Dictionary<string, string> PluralToKind = new(StringComparer.OrdinalIgnoreCase)
{
["servers"] = "Server",
["switches"] = "Switch",
["firewalls"] = "Firewall",
["routers"] = "Router",
["accesspoints"] = "AccessPoint",
["ups"] = "Ups",
};

public BundledHardwareTemplateStore(string basePath)
{
_basePath = basePath;
_deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithCaseInsensitivePropertyMatching()
.WithTypeConverter(new StorageSizeYamlConverter())
.WithTypeConverter(new NotesStringYamlConverter())
.WithTypeDiscriminatingNodeDeserializer(options =>
{
options.AddKeyValueTypeDiscriminator<Resource>("kind", new Dictionary<string, Type>
{
{ Server.KindLabel, typeof(Server) },
{ Switch.KindLabel, typeof(Switch) },
{ Firewall.KindLabel, typeof(Firewall) },
{ Router.KindLabel, typeof(Router) },
{ AccessPoint.KindLabel, typeof(AccessPoint) },
{ Ups.KindLabel, typeof(Ups) },
});
})
.Build();
}

/// <inheritdoc />
public async Task<IReadOnlyList<HardwareTemplate>> GetAllByKindAsync(string kind)
{
var all = await LoadAsync();
return all
.Where(t => t.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.Model, StringComparer.OrdinalIgnoreCase)
.ToList();
}

/// <inheritdoc />
public async Task<HardwareTemplate?> GetByIdAsync(string templateId)
{
var all = await LoadAsync();
return all.FirstOrDefault(t => t.Id.Equals(templateId, StringComparison.OrdinalIgnoreCase));
}

/// <inheritdoc />
public async Task<IReadOnlyList<HardwareTemplate>> GetAllAsync()
{
var all = await LoadAsync();
return all
.OrderBy(t => t.Kind, StringComparer.OrdinalIgnoreCase)
.ThenBy(t => t.Model, StringComparer.OrdinalIgnoreCase)
.ToList();
}

private async Task<List<HardwareTemplate>> LoadAsync()
{
if (_cache is not null)
return _cache;

await _loadLock.WaitAsync();
try
{
if (_cache is not null)
return _cache;

_cache = await ScanTemplatesAsync();
return _cache;
}
finally
{
_loadLock.Release();
}
}

private Task<List<HardwareTemplate>> ScanTemplatesAsync()
{
var templates = new List<HardwareTemplate>();

if (!Directory.Exists(_basePath))
return Task.FromResult(templates);

foreach (var kindDir in Directory.GetDirectories(_basePath))
{
var dirName = Path.GetFileName(kindDir);
if (!PluralToKind.TryGetValue(dirName, out var kind))
continue;

foreach (var file in Directory.GetFiles(kindDir, "*.yaml"))
{
try
{
var yaml = File.ReadAllText(file);
var resource = _deserializer.Deserialize<Resource>(yaml);
if (resource is null)
continue;

resource.Kind = kind;

var model = GetModel(resource) ?? Path.GetFileNameWithoutExtension(file);
var id = $"{kind}/{model}";

if (string.IsNullOrWhiteSpace(resource.Name))
resource.Name = model;

templates.Add(new HardwareTemplate(id, kind, model, resource));
}
catch
{
// Skip malformed template files gracefully
}
}
}

return Task.FromResult(templates);
}

private static string? GetModel(Resource resource) => resource switch
{
Switch s => s.Model,
Router r => r.Model,
Firewall f => f.Model,
AccessPoint ap => ap.Model,
Ups u => u.Model,
_ => null,
};
}
18 changes: 18 additions & 0 deletions RackPeek.Domain/Templates/HardwareTemplate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using RackPeek.Domain.Resources;

namespace RackPeek.Domain.Templates;

/// <summary>
/// Represents a pre-filled hardware specification that can be used as a starting point
/// when adding a new resource. The <see cref="Spec"/> contains all hardware details
/// (ports, model, features) but uses a placeholder name that gets replaced at creation time.
/// </summary>
/// <param name="Id">Unique identifier in the form <c>{Kind}/{Model}</c>.</param>
/// <param name="Kind">Resource kind (e.g. Switch, Router, Firewall).</param>
/// <param name="Model">Human-readable model name used for display.</param>
/// <param name="Spec">Fully populated resource with placeholder name — deep-cloned at use time.</param>
public sealed record HardwareTemplate(
string Id,
string Kind,
string Model,
Resource Spec);
25 changes: 25 additions & 0 deletions RackPeek.Domain/Templates/IHardwareTemplateStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace RackPeek.Domain.Templates;

/// <summary>
/// Read-only store of known hardware templates that can be used to pre-fill
/// resource specifications when adding new resources.
/// </summary>
public interface IHardwareTemplateStore
{
/// <summary>
/// Returns all templates matching the specified resource kind (case-insensitive).
/// </summary>
/// <param name="kind">Resource kind such as "Switch", "Router", or "Firewall".</param>
Task<IReadOnlyList<HardwareTemplate>> GetAllByKindAsync(string kind);

/// <summary>
/// Returns a single template by its identifier, or <c>null</c> if not found.
/// </summary>
/// <param name="templateId">Template identifier in the form <c>{Kind}/{Model}</c>.</param>
Task<HardwareTemplate?> GetByIdAsync(string templateId);

/// <summary>
/// Returns all available templates across all resource kinds.
/// </summary>
Task<IReadOnlyList<HardwareTemplate>> GetAllAsync();
}
Loading
Loading