partly or unimplemented features - now implemented
This commit is contained in:
35
src/Mirror/StellaOps.Mirror.Creator/IMirrorCreatorService.cs
Normal file
35
src/Mirror/StellaOps.Mirror.Creator/IMirrorCreatorService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.Mirror.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// Creates deterministic synchronization plans for mirror sources.
|
||||
/// </summary>
|
||||
public interface IMirrorCreatorService
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds or updates a source configuration.
|
||||
/// </summary>
|
||||
Task UpsertSourceAsync(
|
||||
MirrorSourceConfiguration source,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns configured sources for a tenant in deterministic order.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<MirrorSourceConfiguration>> GetSourcesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deterministic sync plan for the given tenant.
|
||||
/// </summary>
|
||||
Task<MirrorSyncPlan> CreateSyncPlanAsync(
|
||||
MirrorSyncRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records execution outcome for a plan item so future plans can be incremental.
|
||||
/// </summary>
|
||||
Task RecordSyncResultAsync(
|
||||
MirrorSyncResult result,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Mirror.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory mirror creator that provides deterministic planning behavior.
|
||||
/// </summary>
|
||||
public sealed class InMemoryMirrorCreatorService : IMirrorCreatorService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly MirrorCreatorOptions _options;
|
||||
private readonly ConcurrentDictionary<string, SortedDictionary<string, MirrorSourceConfiguration>> _sourcesByTenant = new();
|
||||
private readonly ConcurrentDictionary<(string TenantId, string SourceId), string> _cursorBySource = new();
|
||||
private readonly ConcurrentDictionary<(string PlanId, string TenantId), byte> _knownPlans = new();
|
||||
|
||||
public InMemoryMirrorCreatorService(
|
||||
TimeProvider? timeProvider = null,
|
||||
IOptions<MirrorCreatorOptions>? options = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_options = options?.Value ?? new MirrorCreatorOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpsertSourceAsync(
|
||||
MirrorSourceConfiguration source,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ValidateSource(source);
|
||||
|
||||
var tenantId = source.NormalizedTenantId;
|
||||
var sourceId = source.NormalizedSourceId;
|
||||
var normalized = source with
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SourceId = sourceId,
|
||||
};
|
||||
|
||||
var bucket = _sourcesByTenant.GetOrAdd(
|
||||
tenantId,
|
||||
static _ => new SortedDictionary<string, MirrorSourceConfiguration>(StringComparer.Ordinal));
|
||||
|
||||
lock (bucket)
|
||||
{
|
||||
bucket[sourceId] = normalized;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<MirrorSourceConfiguration>> GetSourcesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var normalizedTenant = NormalizeTenant(tenantId);
|
||||
|
||||
if (!_sourcesByTenant.TryGetValue(normalizedTenant, out var bucket))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MirrorSourceConfiguration>>(Array.Empty<MirrorSourceConfiguration>());
|
||||
}
|
||||
|
||||
lock (bucket)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MirrorSourceConfiguration>>(bucket.Values.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MirrorSyncPlan> CreateSyncPlanAsync(
|
||||
MirrorSyncRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var tenantId = NormalizeTenant(request.TenantId);
|
||||
var now = request.RequestedAt ?? _timeProvider.GetUtcNow();
|
||||
var sources = await GetSourcesAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = new List<MirrorSyncPlanItem>();
|
||||
foreach (var source in sources.Where(static s => s.Enabled))
|
||||
{
|
||||
var cursorKey = (tenantId, source.NormalizedSourceId);
|
||||
_cursorBySource.TryGetValue(cursorKey, out var previousCursor);
|
||||
var mode = previousCursor is null ? MirrorSyncMode.Full : MirrorSyncMode.Incremental;
|
||||
|
||||
var contentKinds = ExpandContentKinds(source.ContentKinds);
|
||||
var outputPath = BuildOutputPath(tenantId, source.NormalizedSourceId, now);
|
||||
items.Add(new MirrorSyncPlanItem(
|
||||
SourceId: source.NormalizedSourceId,
|
||||
Mode: mode,
|
||||
ContentKinds: contentKinds,
|
||||
Cursor: previousCursor,
|
||||
OutputPath: outputPath));
|
||||
}
|
||||
|
||||
var planId = ComputePlanId(tenantId, now, items);
|
||||
_knownPlans[(planId, tenantId)] = 0;
|
||||
|
||||
return new MirrorSyncPlan(
|
||||
PlanId: planId,
|
||||
TenantId: tenantId,
|
||||
CreatedAt: now,
|
||||
Items: items);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordSyncResultAsync(
|
||||
MirrorSyncResult result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var tenantId = NormalizeTenant(result.TenantId);
|
||||
var sourceId = MirrorFormatting.NormalizeId(result.SourceId);
|
||||
if (!_knownPlans.ContainsKey((result.PlanId, tenantId)))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown plan '{result.PlanId}' for tenant '{tenantId}'.");
|
||||
}
|
||||
|
||||
if (!result.Succeeded || string.IsNullOrWhiteSpace(result.NewCursor))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_cursorBySource[(tenantId, sourceId)] = result.NewCursor.Trim();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string BuildOutputPath(string tenantId, string sourceId, DateTimeOffset createdAtUtc)
|
||||
{
|
||||
var root = _options.OutputRoot.Trim().Replace('\\', '/').Trim('/');
|
||||
var timestamp = MirrorFormatting.FormatTimestamp(createdAtUtc);
|
||||
return $"{root}/{tenantId}/{sourceId}/{timestamp}.bundle.json";
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new ArgumentException("TenantId is required.", nameof(tenantId));
|
||||
}
|
||||
|
||||
return MirrorFormatting.NormalizeId(tenantId);
|
||||
}
|
||||
|
||||
private static void ValidateSource(MirrorSourceConfiguration source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source.TenantId))
|
||||
{
|
||||
throw new ArgumentException("TenantId is required.", nameof(source));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.SourceId))
|
||||
{
|
||||
throw new ArgumentException("SourceId is required.", nameof(source));
|
||||
}
|
||||
|
||||
if (!source.SourceUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("SourceUri must be absolute.", nameof(source));
|
||||
}
|
||||
|
||||
if (!source.TargetUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException("TargetUri must be absolute.", nameof(source));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MirrorContentKind> ExpandContentKinds(MirrorContentKind kinds)
|
||||
{
|
||||
if (kinds == 0)
|
||||
{
|
||||
return Array.Empty<MirrorContentKind>();
|
||||
}
|
||||
|
||||
return Enum.GetValues<MirrorContentKind>()
|
||||
.Where(value => value != 0 && kinds.HasFlag(value))
|
||||
.OrderBy(static value => value)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string ComputePlanId(
|
||||
string tenantId,
|
||||
DateTimeOffset createdAt,
|
||||
IReadOnlyList<MirrorSyncPlanItem> items)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(tenantId).Append('\n');
|
||||
builder.Append(createdAt.UtcDateTime.ToString("O")).Append('\n');
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
builder.Append(item.SourceId).Append('|')
|
||||
.Append(item.Mode).Append('|')
|
||||
.Append(string.Join(',', item.ContentKinds)).Append('|')
|
||||
.Append(item.Cursor ?? string.Empty).Append('|')
|
||||
.Append(item.OutputPath)
|
||||
.Append('\n');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
12
src/Mirror/StellaOps.Mirror.Creator/MirrorCreatorOptions.cs
Normal file
12
src/Mirror/StellaOps.Mirror.Creator/MirrorCreatorOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Mirror.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for deterministic mirror plan generation.
|
||||
/// </summary>
|
||||
public sealed class MirrorCreatorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root path used for generated output bundle paths.
|
||||
/// </summary>
|
||||
public string OutputRoot { get; set; } = "mirror-bundles";
|
||||
}
|
||||
85
src/Mirror/StellaOps.Mirror.Creator/MirrorModels.cs
Normal file
85
src/Mirror/StellaOps.Mirror.Creator/MirrorModels.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Mirror.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// Content types that can be mirrored into an offline bundle.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MirrorContentKind
|
||||
{
|
||||
Advisories = 1 << 0,
|
||||
Vex = 1 << 1,
|
||||
Sbom = 1 << 2,
|
||||
Images = 1 << 3,
|
||||
Dashboards = 1 << 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronization mode for a mirror source.
|
||||
/// </summary>
|
||||
public enum MirrorSyncMode
|
||||
{
|
||||
Full = 0,
|
||||
Incremental = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source configuration for mirror planning.
|
||||
/// </summary>
|
||||
public sealed record MirrorSourceConfiguration(
|
||||
string TenantId,
|
||||
string SourceId,
|
||||
Uri SourceUri,
|
||||
Uri TargetUri,
|
||||
MirrorContentKind ContentKinds,
|
||||
bool Enabled = true)
|
||||
{
|
||||
public string NormalizedTenantId => TenantId.Trim().ToLowerInvariant();
|
||||
public string NormalizedSourceId => SourceId.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input to create a mirror synchronization plan.
|
||||
/// </summary>
|
||||
public sealed record MirrorSyncRequest(
|
||||
string TenantId,
|
||||
DateTimeOffset? RequestedAt = null);
|
||||
|
||||
/// <summary>
|
||||
/// Planned synchronization work for a source.
|
||||
/// </summary>
|
||||
public sealed record MirrorSyncPlanItem(
|
||||
string SourceId,
|
||||
MirrorSyncMode Mode,
|
||||
IReadOnlyList<MirrorContentKind> ContentKinds,
|
||||
string? Cursor,
|
||||
string OutputPath);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic plan returned by the mirror creator service.
|
||||
/// </summary>
|
||||
public sealed record MirrorSyncPlan(
|
||||
string PlanId,
|
||||
string TenantId,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<MirrorSyncPlanItem> Items);
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing a mirror plan item.
|
||||
/// </summary>
|
||||
public sealed record MirrorSyncResult(
|
||||
string PlanId,
|
||||
string TenantId,
|
||||
string SourceId,
|
||||
bool Succeeded,
|
||||
string? NewCursor,
|
||||
DateTimeOffset CompletedAt);
|
||||
|
||||
internal static class MirrorFormatting
|
||||
{
|
||||
public static string NormalizeId(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
public static string FormatTimestamp(DateTimeOffset value) =>
|
||||
value.UtcDateTime.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Mirror.Creator;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection wiring for mirror creator services.
|
||||
/// </summary>
|
||||
public static class MirrorServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers deterministic mirror creator services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMirrorCreator(
|
||||
this IServiceCollection services,
|
||||
Action<MirrorCreatorOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<MirrorCreatorOptions>()
|
||||
.Configure(options => configureOptions?.Invoke(options));
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IMirrorCreatorService, InMemoryMirrorCreatorService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Mirror.Creator;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Mirror.Creator.Core.Tests;
|
||||
|
||||
public sealed class MirrorCreatorServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateSyncPlanAsync_ProducesDeterministicSortedFullPlan()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 8, 12, 30, 0, TimeSpan.Zero);
|
||||
var service = CreateService(now);
|
||||
|
||||
await service.UpsertSourceAsync(new MirrorSourceConfiguration(
|
||||
TenantId: "acme",
|
||||
SourceId: "zeta",
|
||||
SourceUri: new Uri("https://mirror.example/zeta"),
|
||||
TargetUri: new Uri("file:///offline/zeta"),
|
||||
ContentKinds: MirrorContentKind.Advisories | MirrorContentKind.Vex));
|
||||
|
||||
await service.UpsertSourceAsync(new MirrorSourceConfiguration(
|
||||
TenantId: "acme",
|
||||
SourceId: "alpha",
|
||||
SourceUri: new Uri("https://mirror.example/alpha"),
|
||||
TargetUri: new Uri("file:///offline/alpha"),
|
||||
ContentKinds: MirrorContentKind.Sbom | MirrorContentKind.Images));
|
||||
|
||||
var first = await service.CreateSyncPlanAsync(new MirrorSyncRequest("acme", now));
|
||||
var second = await service.CreateSyncPlanAsync(new MirrorSyncRequest("acme", now));
|
||||
|
||||
Assert.Equal(first.PlanId, second.PlanId);
|
||||
Assert.Collection(first.Items,
|
||||
item =>
|
||||
{
|
||||
Assert.Equal("alpha", item.SourceId);
|
||||
Assert.Equal(MirrorSyncMode.Full, item.Mode);
|
||||
Assert.Null(item.Cursor);
|
||||
Assert.Equal("mirror-bundles/acme/alpha/20260208123000.bundle.json", item.OutputPath);
|
||||
},
|
||||
item =>
|
||||
{
|
||||
Assert.Equal("zeta", item.SourceId);
|
||||
Assert.Equal(MirrorSyncMode.Full, item.Mode);
|
||||
Assert.Null(item.Cursor);
|
||||
Assert.Equal("mirror-bundles/acme/zeta/20260208123000.bundle.json", item.OutputPath);
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RecordSyncResultAsync_EnablesIncrementalPlanning()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 8, 13, 0, 0, TimeSpan.Zero);
|
||||
var service = CreateService(now);
|
||||
|
||||
await service.UpsertSourceAsync(new MirrorSourceConfiguration(
|
||||
TenantId: "acme",
|
||||
SourceId: "alpha",
|
||||
SourceUri: new Uri("https://mirror.example/alpha"),
|
||||
TargetUri: new Uri("file:///offline/alpha"),
|
||||
ContentKinds: MirrorContentKind.Advisories));
|
||||
|
||||
var initialPlan = await service.CreateSyncPlanAsync(new MirrorSyncRequest("acme", now));
|
||||
await service.RecordSyncResultAsync(new MirrorSyncResult(
|
||||
PlanId: initialPlan.PlanId,
|
||||
TenantId: "acme",
|
||||
SourceId: "alpha",
|
||||
Succeeded: true,
|
||||
NewCursor: "cursor-20260208",
|
||||
CompletedAt: now.AddMinutes(2)));
|
||||
|
||||
var incrementalPlan = await service.CreateSyncPlanAsync(new MirrorSyncRequest("acme", now.AddMinutes(10)));
|
||||
|
||||
var item = Assert.Single(incrementalPlan.Items);
|
||||
Assert.Equal(MirrorSyncMode.Incremental, item.Mode);
|
||||
Assert.Equal("cursor-20260208", item.Cursor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddMirrorCreator_RegistersServiceSurface()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddMirrorCreator(options => options.OutputRoot = "custom-root");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var service = provider.GetService<IMirrorCreatorService>();
|
||||
var options = provider.GetService<IOptions<MirrorCreatorOptions>>();
|
||||
|
||||
Assert.NotNull(service);
|
||||
Assert.NotNull(options);
|
||||
Assert.Equal("custom-root", options!.Value.OutputRoot);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RecordSyncResultAsync_RejectsUnknownPlan()
|
||||
{
|
||||
var service = CreateService(new DateTimeOffset(2026, 2, 8, 14, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.RecordSyncResultAsync(new MirrorSyncResult(
|
||||
PlanId: "missing-plan",
|
||||
TenantId: "acme",
|
||||
SourceId: "alpha",
|
||||
Succeeded: true,
|
||||
NewCursor: "cursor",
|
||||
CompletedAt: DateTimeOffset.UtcNow)));
|
||||
|
||||
Assert.Contains("Unknown plan", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IMirrorCreatorService CreateService(DateTimeOffset now)
|
||||
{
|
||||
var options = Options.Create(new MirrorCreatorOptions { OutputRoot = "mirror-bundles" });
|
||||
return new InMemoryMirrorCreatorService(new FixedTimeProvider(now), options);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\StellaOps.Mirror.Creator\\StellaOps.Mirror.Creator.Core.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.TestKit\\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user