Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
public sealed class FileSurfaceCache : ISurfaceCache
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly ILogger<FileSurfaceCache> _logger;
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(StringComparer.Ordinal);
|
||||
|
||||
public FileSurfaceCache(
|
||||
IOptions<SurfaceCacheOptions> options,
|
||||
ILogger<FileSurfaceCache> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
var root = options?.Value?.ResolveRoot();
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
throw new ArgumentException("Surface cache root directory must be provided.", nameof(options));
|
||||
}
|
||||
|
||||
_root = root!;
|
||||
}
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
Func<T, ReadOnlyMemory<byte>> serializer,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var path = ResolvePath(key);
|
||||
if (TryRead(path, deserializer, out var value))
|
||||
{
|
||||
_logger.LogTrace("Surface cache hit for {Key}.", key);
|
||||
return value!;
|
||||
}
|
||||
|
||||
var gate = _locks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (TryRead(path, deserializer, out value))
|
||||
{
|
||||
_logger.LogTrace("Surface cache race recovered for {Key}.", key);
|
||||
return value!;
|
||||
}
|
||||
|
||||
value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
var payload = serializer(value);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllBytesAsync(path, payload.ToArray(), cancellationToken).ConfigureAwait(false);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<T?> TryGetAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var path = ResolvePath(key);
|
||||
return Task.FromResult(TryRead(path, deserializer, out var value) ? value : default);
|
||||
}
|
||||
|
||||
public async Task SetAsync(
|
||||
SurfaceCacheKey key,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
var path = ResolvePath(key);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllBytesAsync(path, payload.ToArray(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string ResolvePath(SurfaceCacheKey key)
|
||||
{
|
||||
var hash = ComputeHash(key.ContentKey);
|
||||
var tenant = Sanitize(key.Tenant);
|
||||
var ns = Sanitize(key.Namespace);
|
||||
return Path.Combine(_root, ns, tenant, hash[..2], hash[2..4], $"{hash}.bin");
|
||||
}
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "default"
|
||||
: value.Replace('/', '_').Replace('\\', '_');
|
||||
|
||||
private static bool TryRead<T>(string path, Func<ReadOnlyMemory<byte>, T> deserializer, out T? value)
|
||||
{
|
||||
value = default;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
value = deserializer(bytes);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Provides content-addressable storage for surface artefacts.
|
||||
/// </summary>
|
||||
public interface ISurfaceCache
|
||||
{
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
Func<T, ReadOnlyMemory<byte>> serializer,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<T?> TryGetAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetAsync(
|
||||
SurfaceCacheKey key,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string ConfigurationSection = "Surface:Cache";
|
||||
|
||||
public static IServiceCollection AddSurfaceFileCache(
|
||||
this IServiceCollection services,
|
||||
Action<SurfaceCacheOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<SurfaceCacheOptions>()
|
||||
.BindConfiguration(ConfigurationSection);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<ISurfaceCache, FileSurfaceCache>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<SurfaceCacheOptions>, SurfaceCacheOptionsValidator>());
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class SurfaceCacheOptionsValidator : IValidateOptions<SurfaceCacheOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, SurfaceCacheOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Options cannot be null.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = options.ResolveRoot();
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Root directory cannot be empty.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ValidateOptionsResult.Fail($"Failed to resolve surface cache root: {ex.Message}");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
|
||||
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
|
||||
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.7.25380.108" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
internal static class SurfaceCacheJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
static SurfaceCacheJsonSerializer()
|
||||
{
|
||||
Options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
}
|
||||
|
||||
public static ReadOnlyMemory<byte> Serialize<T>(T value)
|
||||
{
|
||||
return JsonSerializer.SerializeToUtf8Bytes(value, Options);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(payload.Span, Options)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a cached artefact within the surface file store.
|
||||
/// </summary>
|
||||
public sealed record SurfaceCacheKey(string Namespace, string Tenant, string ContentKey)
|
||||
{
|
||||
public override string ToString()
|
||||
=> $"{Namespace}/{Tenant}/{ContentKey}";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the on-disk storage used by the surface cache.
|
||||
/// </summary>
|
||||
public sealed class SurfaceCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root directory where cached payloads are stored. Defaults to a deterministic path under the temporary directory.
|
||||
/// </summary>
|
||||
public string? RootDirectory { get; set; }
|
||||
|
||||
internal string ResolveRoot()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(RootDirectory))
|
||||
{
|
||||
return RootDirectory!;
|
||||
}
|
||||
|
||||
return Path.Combine(Path.GetTempPath(), "stellaops", "surface-cache");
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SURFACE-FS-01 | TODO | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Author `docs/modules/scanner/design/surface-fs.md` defining cache layout, pointer schema, tenancy, and offline handling. | Spec merged; reviewers from Scanner/Zastava sign off; component map cross-link drafted. |
|
||||
| SURFACE-FS-02 | TODO | Scanner Guild | SURFACE-FS-01 | Implement `StellaOps.Scanner.Surface.FS` core abstractions (writer, reader, manifest models) with deterministic serialization + unit tests. | Library compiles; tests pass; XML docs cover public types. |
|
||||
| SURFACE-FS-01 | DOING (2025-11-02) | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Author `docs/modules/scanner/design/surface-fs.md` defining cache layout, pointer schema, tenancy, and offline handling. | Spec merged; reviewers from Scanner/Zastava sign off; component map cross-link drafted. |
|
||||
| SURFACE-FS-02 | DOING (2025-11-02) | Scanner Guild | SURFACE-FS-01 | Implement `StellaOps.Scanner.Surface.FS` core abstractions (writer, reader, manifest models) with deterministic serialization + unit tests. | Library compiles; tests pass; XML docs cover public types. |
|
||||
| SURFACE-FS-03 | TODO | Scanner Guild | SURFACE-FS-02 | Integrate Surface.FS writer into Scanner Worker analyzer pipeline to persist layer + entry-trace fragments. | Worker produces cache entries in integration tests; observability counters emitted. |
|
||||
| SURFACE-FS-04 | TODO | Zastava Guild | SURFACE-FS-02 | Integrate Surface.FS reader into Zastava Observer runtime drift loop. | Observer validates runtime artefacts via cache; regression tests updated. |
|
||||
| SURFACE-FS-05 | TODO | Scanner Guild, Scheduler Guild | SURFACE-FS-03 | Expose Surface.FS pointers via Scanner WebService reports and coordinate rescan planning with Scheduler. | API contracts updated; Scheduler consumes pointers; docs refreshed. |
|
||||
|
||||
Reference in New Issue
Block a user