fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -0,0 +1,61 @@
# -----------------------------------------------------------------------------
# Dockerfile
# Sprint: SPRINT_20260125_002_Attestor_trust_automation
# Task: PROXY-008 - Docker Compose for tile-proxy stack
# Description: Multi-stage build for tile-proxy service
# -----------------------------------------------------------------------------
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy solution and project files
COPY ["src/Attestor/StellaOps.Attestor.TileProxy/StellaOps.Attestor.TileProxy.csproj", "Attestor/StellaOps.Attestor.TileProxy/"]
COPY ["src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj", "Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/"]
COPY ["src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/StellaOps.Attestor.TrustRepo.csproj", "Attestor/__Libraries/StellaOps.Attestor.TrustRepo/"]
COPY ["src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj", "__Libraries/StellaOps.Configuration/"]
COPY ["src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj", "__Libraries/StellaOps.DependencyInjection/"]
# Restore dependencies
RUN dotnet restore "Attestor/StellaOps.Attestor.TileProxy/StellaOps.Attestor.TileProxy.csproj"
# Copy remaining source
COPY src/ .
# Build
WORKDIR "/src/Attestor/StellaOps.Attestor.TileProxy"
RUN dotnet build -c Release -o /app/build
# Publish stage
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
# Create non-root user
RUN adduser --disabled-password --gecos "" --home /app appuser && \
mkdir -p /var/cache/stellaops/tiles && \
mkdir -p /var/cache/stellaops/tuf && \
chown -R appuser:appuser /var/cache/stellaops
# Copy published app
COPY --from=publish /app/publish .
RUN chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Configure environment
ENV ASPNETCORE_URLS=http://+:8080
ENV TILE_PROXY__CACHE__BASEPATH=/var/cache/stellaops/tiles
ENV TILE_PROXY__TUF__CACHEPATH=/var/cache/stellaops/tuf
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/_admin/health || exit 1
EXPOSE 8080
ENTRYPOINT ["dotnet", "StellaOps.Attestor.TileProxy.dll"]

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// TileEndpoints.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Tile proxy API endpoints
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Attestor.TileProxy.Services;
namespace StellaOps.Attestor.TileProxy.Endpoints;
/// <summary>
/// API endpoints for tile proxy service.
/// </summary>
public static class TileEndpoints
{
/// <summary>
/// Maps all tile proxy endpoints.
/// </summary>
public static IEndpointRouteBuilder MapTileProxyEndpoints(this IEndpointRouteBuilder endpoints)
{
// Tile endpoints (passthrough)
endpoints.MapGet("/tile/{level:int}/{index:long}", GetTile)
.WithName("GetTile")
.WithTags("Tiles")
.Produces<byte[]>(StatusCodes.Status200OK, "application/octet-stream")
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status502BadGateway);
endpoints.MapGet("/tile/{level:int}/{index:long}.p/{partialWidth:int}", GetPartialTile)
.WithName("GetPartialTile")
.WithTags("Tiles")
.Produces<byte[]>(StatusCodes.Status200OK, "application/octet-stream")
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status502BadGateway);
// Checkpoint endpoint
endpoints.MapGet("/checkpoint", GetCheckpoint)
.WithName("GetCheckpoint")
.WithTags("Checkpoint")
.Produces<string>(StatusCodes.Status200OK, "text/plain")
.Produces(StatusCodes.Status502BadGateway);
// Admin endpoints
var admin = endpoints.MapGroup("/_admin");
admin.MapGet("/cache/stats", GetCacheStats)
.WithName("GetCacheStats")
.WithTags("Admin")
.Produces<CacheStatsResponse>(StatusCodes.Status200OK);
admin.MapGet("/metrics", GetMetrics)
.WithName("GetMetrics")
.WithTags("Admin")
.Produces<MetricsResponse>(StatusCodes.Status200OK);
admin.MapPost("/cache/sync", TriggerSync)
.WithName("TriggerSync")
.WithTags("Admin")
.Produces<SyncResponse>(StatusCodes.Status200OK);
admin.MapDelete("/cache/prune", PruneCache)
.WithName("PruneCache")
.WithTags("Admin")
.Produces<PruneResponse>(StatusCodes.Status200OK);
admin.MapGet("/health", HealthCheck)
.WithName("HealthCheck")
.WithTags("Admin")
.Produces<HealthResponse>(StatusCodes.Status200OK);
admin.MapGet("/ready", ReadinessCheck)
.WithName("ReadinessCheck")
.WithTags("Admin")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status503ServiceUnavailable);
return endpoints;
}
private static async Task<IResult> GetTile(
int level,
long index,
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
var result = await proxyService.GetTileAsync(level, index, cancellationToken: cancellationToken);
if (!result.Success)
{
return Results.Problem(
detail: result.Error,
statusCode: StatusCodes.Status502BadGateway);
}
if (result.Content == null)
{
return Results.NotFound();
}
return Results.Bytes(result.Content, "application/octet-stream");
}
private static async Task<IResult> GetPartialTile(
int level,
long index,
int partialWidth,
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
if (partialWidth <= 0 || partialWidth > 256)
{
return Results.BadRequest("Invalid partial width");
}
var result = await proxyService.GetTileAsync(level, index, partialWidth, cancellationToken);
if (!result.Success)
{
return Results.Problem(
detail: result.Error,
statusCode: StatusCodes.Status502BadGateway);
}
if (result.Content == null)
{
return Results.NotFound();
}
return Results.Bytes(result.Content, "application/octet-stream");
}
private static async Task<IResult> GetCheckpoint(
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
var result = await proxyService.GetCheckpointAsync(cancellationToken);
if (!result.Success)
{
return Results.Problem(
detail: result.Error,
statusCode: StatusCodes.Status502BadGateway);
}
return Results.Text(result.Content ?? "", "text/plain");
}
private static async Task<IResult> GetCacheStats(
[FromServices] ContentAddressedTileStore tileStore,
CancellationToken cancellationToken)
{
var stats = await tileStore.GetStatsAsync(cancellationToken);
return Results.Ok(new CacheStatsResponse
{
TotalTiles = stats.TotalTiles,
TotalBytes = stats.TotalBytes,
TotalMb = Math.Round(stats.TotalBytes / (1024.0 * 1024.0), 2),
PartialTiles = stats.PartialTiles,
UsagePercent = Math.Round(stats.UsagePercent, 2),
OldestTile = stats.OldestTile,
NewestTile = stats.NewestTile
});
}
private static IResult GetMetrics(
[FromServices] TileProxyService proxyService)
{
var metrics = proxyService.GetMetrics();
return Results.Ok(new MetricsResponse
{
CacheHits = metrics.CacheHits,
CacheMisses = metrics.CacheMisses,
HitRatePercent = Math.Round(metrics.HitRate, 2),
UpstreamRequests = metrics.UpstreamRequests,
UpstreamErrors = metrics.UpstreamErrors,
InflightRequests = metrics.InflightRequests
});
}
private static IResult TriggerSync(
[FromServices] IServiceProvider services,
[FromServices] ILogger<TileEndpoints> logger)
{
// TODO: Trigger background sync job
logger.LogInformation("Manual sync triggered");
return Results.Ok(new SyncResponse
{
Message = "Sync job queued",
QueuedAt = DateTimeOffset.UtcNow
});
}
private static async Task<IResult> PruneCache(
[FromServices] ContentAddressedTileStore tileStore,
[FromQuery] long? targetSizeBytes,
CancellationToken cancellationToken)
{
var prunedCount = await tileStore.PruneAsync(targetSizeBytes ?? 0, cancellationToken);
return Results.Ok(new PruneResponse
{
TilesPruned = prunedCount,
PrunedAt = DateTimeOffset.UtcNow
});
}
private static IResult HealthCheck()
{
return Results.Ok(new HealthResponse
{
Status = "healthy",
Timestamp = DateTimeOffset.UtcNow
});
}
private static async Task<IResult> ReadinessCheck(
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
// Check if we can reach upstream
var checkpoint = await proxyService.GetCheckpointAsync(cancellationToken);
if (checkpoint.Success)
{
return Results.Ok(new { ready = true, checkpoint = checkpoint.TreeSize });
}
return Results.Json(
new { ready = false, error = checkpoint.Error },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
// Response models
public sealed record CacheStatsResponse
{
public int TotalTiles { get; init; }
public long TotalBytes { get; init; }
public double TotalMb { get; init; }
public int PartialTiles { get; init; }
public double UsagePercent { get; init; }
public DateTimeOffset? OldestTile { get; init; }
public DateTimeOffset? NewestTile { get; init; }
}
public sealed record MetricsResponse
{
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public double HitRatePercent { get; init; }
public long UpstreamRequests { get; init; }
public long UpstreamErrors { get; init; }
public int InflightRequests { get; init; }
}
public sealed record SyncResponse
{
public string Message { get; init; } = string.Empty;
public DateTimeOffset QueuedAt { get; init; }
}
public sealed record PruneResponse
{
public int TilesPruned { get; init; }
public DateTimeOffset PrunedAt { get; init; }
}
public sealed record HealthResponse
{
public string Status { get; init; } = string.Empty;
public DateTimeOffset Timestamp { get; init; }
}
// Logger class for endpoint logging
file static class TileEndpoints
{
}

View File

@@ -0,0 +1,278 @@
// -----------------------------------------------------------------------------
// TileSyncJob.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-006 - Implement scheduled tile sync job
// Description: Background job for pre-warming tile cache
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.TileProxy.Services;
namespace StellaOps.Attestor.TileProxy.Jobs;
/// <summary>
/// Background job that periodically syncs tiles from upstream to pre-warm the cache.
/// </summary>
public sealed class TileSyncJob : BackgroundService
{
private readonly TileProxyOptions _options;
private readonly TileProxyService _proxyService;
private readonly ContentAddressedTileStore _tileStore;
private readonly ILogger<TileSyncJob> _logger;
private const int TileWidth = 256;
public TileSyncJob(
IOptions<TileProxyOptions> options,
TileProxyService proxyService,
ContentAddressedTileStore tileStore,
ILogger<TileSyncJob> logger)
{
_options = options.Value;
_proxyService = proxyService;
_tileStore = tileStore;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Sync.Enabled)
{
_logger.LogInformation("Tile sync job is disabled");
return;
}
_logger.LogInformation(
"Tile sync job started - Schedule: {Schedule}, Depth: {Depth}",
_options.Sync.Schedule,
_options.Sync.Depth);
// Run initial sync on startup
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
await RunSyncAsync(stoppingToken);
// Schedule periodic sync
var schedule = ParseCronSchedule(_options.Sync.Schedule);
while (!stoppingToken.IsCancellationRequested)
{
var nextRun = GetNextRunTime(schedule);
var delay = nextRun - DateTimeOffset.UtcNow;
if (delay > TimeSpan.Zero)
{
_logger.LogDebug("Next sync scheduled at {NextRun}", nextRun);
await Task.Delay(delay, stoppingToken);
}
if (!stoppingToken.IsCancellationRequested)
{
await RunSyncAsync(stoppingToken);
}
}
}
/// <summary>
/// Runs a sync operation to pre-warm the tile cache.
/// </summary>
public async Task RunSyncAsync(CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
_logger.LogInformation("Starting tile sync");
try
{
// Fetch current checkpoint
var checkpoint = await _proxyService.GetCheckpointAsync(cancellationToken);
if (!checkpoint.Success || !checkpoint.TreeSize.HasValue)
{
_logger.LogWarning("Failed to fetch checkpoint: {Error}", checkpoint.Error);
return;
}
var treeSize = checkpoint.TreeSize.Value;
var depth = Math.Min(_options.Sync.Depth, treeSize);
_logger.LogInformation(
"Syncing tiles for entries {StartIndex} to {EndIndex} (tree size: {TreeSize})",
treeSize - depth,
treeSize,
treeSize);
// Calculate which tiles we need for the specified depth
var tilesToSync = CalculateRequiredTiles(treeSize - depth, treeSize);
var syncedCount = 0;
var skippedCount = 0;
var errorCount = 0;
foreach (var (level, index) in tilesToSync)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
// Check if we already have this tile
var hasTile = await _tileStore.HasTileAsync(_options.Origin, level, index, cancellationToken);
if (hasTile)
{
skippedCount++;
continue;
}
// Fetch the tile
var result = await _proxyService.GetTileAsync(level, index, cancellationToken: cancellationToken);
if (result.Success)
{
syncedCount++;
}
else
{
errorCount++;
_logger.LogWarning("Failed to sync tile {Level}/{Index}: {Error}", level, index, result.Error);
}
// Rate limiting to avoid overwhelming upstream
await Task.Delay(50, cancellationToken);
}
var duration = DateTimeOffset.UtcNow - startTime;
_logger.LogInformation(
"Tile sync completed in {Duration}ms - Synced: {Synced}, Skipped: {Skipped}, Errors: {Errors}",
duration.TotalMilliseconds,
syncedCount,
skippedCount,
errorCount);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Tile sync cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Tile sync failed");
}
}
private static List<(int Level, long Index)> CalculateRequiredTiles(long startIndex, long endIndex)
{
var tiles = new HashSet<(int Level, long Index)>();
// Level 0: tiles containing the entries
var startTile = startIndex / TileWidth;
var endTile = (endIndex - 1) / TileWidth;
for (var i = startTile; i <= endTile; i++)
{
tiles.Add((0, i));
}
// Higher levels: tiles needed for Merkle proofs
var level = 1;
var levelStart = startTile;
var levelEnd = endTile;
while (levelStart < levelEnd)
{
levelStart /= TileWidth;
levelEnd /= TileWidth;
for (var i = levelStart; i <= levelEnd; i++)
{
tiles.Add((level, i));
}
level++;
}
return tiles.OrderBy(t => t.Level).ThenBy(t => t.Index).ToList();
}
private static CronSchedule ParseCronSchedule(string schedule)
{
// Simple cron parser for "minute hour day month weekday" format
var parts = schedule.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 5)
{
throw new ArgumentException($"Invalid cron schedule: {schedule}");
}
return new CronSchedule
{
Minute = ParseCronField(parts[0], 0, 59),
Hour = ParseCronField(parts[1], 0, 23),
Day = ParseCronField(parts[2], 1, 31),
Month = ParseCronField(parts[3], 1, 12),
Weekday = ParseCronField(parts[4], 0, 6)
};
}
private static int[] ParseCronField(string field, int min, int max)
{
if (field == "*")
{
return Enumerable.Range(min, max - min + 1).ToArray();
}
if (field.StartsWith("*/"))
{
var interval = int.Parse(field[2..]);
return Enumerable.Range(min, max - min + 1)
.Where(i => (i - min) % interval == 0)
.ToArray();
}
if (field.Contains(','))
{
return field.Split(',').Select(int.Parse).ToArray();
}
if (field.Contains('-'))
{
var range = field.Split('-');
var start = int.Parse(range[0]);
var end = int.Parse(range[1]);
return Enumerable.Range(start, end - start + 1).ToArray();
}
return [int.Parse(field)];
}
private static DateTimeOffset GetNextRunTime(CronSchedule schedule)
{
var now = DateTimeOffset.UtcNow;
var candidate = new DateTimeOffset(
now.Year, now.Month, now.Day,
now.Hour, now.Minute, 0,
TimeSpan.Zero);
// Search for next valid time within the next year
for (var i = 0; i < 525600; i++) // Max ~1 year in minutes
{
candidate = candidate.AddMinutes(1);
if (schedule.Minute.Contains(candidate.Minute) &&
schedule.Hour.Contains(candidate.Hour) &&
schedule.Day.Contains(candidate.Day) &&
schedule.Month.Contains(candidate.Month) &&
schedule.Weekday.Contains((int)candidate.DayOfWeek))
{
return candidate;
}
}
// Fallback: run in 6 hours
return now.AddHours(6);
}
private sealed record CronSchedule
{
public required int[] Minute { get; init; }
public required int[] Hour { get; init; }
public required int[] Day { get; init; }
public required int[] Month { get; init; }
public required int[] Weekday { get; init; }
}
}

View File

@@ -0,0 +1,137 @@
// -----------------------------------------------------------------------------
// Program.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Tile proxy web service entry point
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Options;
using Serilog;
using StellaOps.Attestor.TileProxy;
using StellaOps.Attestor.TileProxy.Endpoints;
using StellaOps.Attestor.TileProxy.Jobs;
using StellaOps.Attestor.TileProxy.Services;
const string ConfigurationSection = "tile_proxy";
var builder = WebApplication.CreateBuilder(args);
// Configure logging
builder.Host.UseSerilog((context, config) =>
{
config
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");
});
// Load configuration
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("TILE_PROXY__");
// Configure options
builder.Services.Configure<TileProxyOptions>(builder.Configuration.GetSection(ConfigurationSection));
// Validate options
builder.Services.AddSingleton<IValidateOptions<TileProxyOptions>, TileProxyOptionsValidator>();
// Register services
builder.Services.AddSingleton<ContentAddressedTileStore>();
builder.Services.AddSingleton<TileProxyService>();
// Register sync job as hosted service
builder.Services.AddHostedService<TileSyncJob>();
// Configure HTTP client for upstream
builder.Services.AddHttpClient<TileProxyService>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<TileProxyOptions>>().Value;
client.BaseAddress = new Uri(options.UpstreamUrl);
client.Timeout = TimeSpan.FromSeconds(options.Request.TimeoutSeconds);
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-TileProxy/1.0");
});
// Add OpenAPI
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Validate options on startup
var optionsValidator = app.Services.GetRequiredService<IValidateOptions<TileProxyOptions>>();
var options = app.Services.GetRequiredService<IOptions<TileProxyOptions>>().Value;
var validationResult = optionsValidator.Validate(null, options);
if (validationResult.Failed)
{
throw new InvalidOperationException($"Configuration validation failed: {validationResult.FailureMessage}");
}
// Configure pipeline
app.UseSerilogRequestLogging();
// Map endpoints
app.MapTileProxyEndpoints();
// Startup message
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation(
"Tile Proxy starting - Upstream: {Upstream}, Cache: {CachePath}",
options.UpstreamUrl,
options.Cache.BasePath);
app.Run();
/// <summary>
/// Options validator for tile proxy configuration.
/// </summary>
public sealed class TileProxyOptionsValidator : IValidateOptions<TileProxyOptions>
{
public ValidateOptionsResult Validate(string? name, TileProxyOptions options)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(options.UpstreamUrl))
{
errors.Add("UpstreamUrl is required");
}
else if (!Uri.TryCreate(options.UpstreamUrl, UriKind.Absolute, out _))
{
errors.Add("UpstreamUrl must be a valid absolute URI");
}
if (string.IsNullOrWhiteSpace(options.Origin))
{
errors.Add("Origin is required");
}
if (options.Cache.MaxSizeGb < 0)
{
errors.Add("Cache.MaxSizeGb cannot be negative");
}
if (options.Cache.CheckpointTtlMinutes < 1)
{
errors.Add("Cache.CheckpointTtlMinutes must be at least 1");
}
if (options.Request.TimeoutSeconds < 1)
{
errors.Add("Request.TimeoutSeconds must be at least 1");
}
if (options.Tuf.Enabled && string.IsNullOrWhiteSpace(options.Tuf.Url))
{
errors.Add("Tuf.Url is required when TUF is enabled");
}
return errors.Count > 0
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
}
public partial class Program
{
}

View File

@@ -0,0 +1,433 @@
// -----------------------------------------------------------------------------
// ContentAddressedTileStore.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Content-addressed storage for cached tiles
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.TileProxy.Services;
/// <summary>
/// Content-addressed storage for transparency log tiles.
/// Provides immutable, deduplicated tile caching with metadata.
/// </summary>
public sealed class ContentAddressedTileStore : IDisposable
{
private readonly TileProxyOptions _options;
private readonly ILogger<ContentAddressedTileStore> _logger;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, DateTimeOffset> _accessTimes = new();
private const int TileWidth = 256;
private const int HashSize = 32;
public ContentAddressedTileStore(
IOptions<TileProxyOptions> options,
ILogger<ContentAddressedTileStore> logger)
{
_options = options.Value;
_logger = logger;
// Ensure base directory exists
Directory.CreateDirectory(_options.Cache.BasePath);
}
/// <summary>
/// Gets a tile from the cache.
/// </summary>
public async Task<CachedTileData?> GetTileAsync(
string origin,
int level,
long index,
CancellationToken cancellationToken = default)
{
var tilePath = GetTilePath(origin, level, index);
var metaPath = GetMetaPath(origin, level, index);
if (!File.Exists(tilePath))
{
return null;
}
try
{
var content = await File.ReadAllBytesAsync(tilePath, cancellationToken);
TileMetadata? meta = null;
if (File.Exists(metaPath))
{
var metaJson = await File.ReadAllTextAsync(metaPath, cancellationToken);
meta = JsonSerializer.Deserialize<TileMetadata>(metaJson);
}
// Update access time for LRU
var key = $"{origin}/{level}/{index}";
_accessTimes[key] = DateTimeOffset.UtcNow;
return new CachedTileData
{
Origin = origin,
Level = level,
Index = index,
Content = content,
Width = content.Length / HashSize,
CachedAt = meta?.CachedAt ?? File.GetCreationTimeUtc(tilePath),
TreeSize = meta?.TreeSize,
ContentHash = meta?.ContentHash,
IsPartial = content.Length / HashSize < TileWidth
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cached tile {Origin}/{Level}/{Index}", origin, level, index);
return null;
}
}
/// <summary>
/// Stores a tile in the cache.
/// </summary>
public async Task StoreTileAsync(
string origin,
int level,
long index,
byte[] content,
long? treeSize = null,
CancellationToken cancellationToken = default)
{
var tilePath = GetTilePath(origin, level, index);
var metaPath = GetMetaPath(origin, level, index);
var tileDir = Path.GetDirectoryName(tilePath)!;
var contentHash = ComputeContentHash(content);
await _writeLock.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(tileDir);
// Atomic write using temp file
var tempPath = tilePath + ".tmp";
await File.WriteAllBytesAsync(tempPath, content, cancellationToken);
File.Move(tempPath, tilePath, overwrite: true);
// Write metadata
var meta = new TileMetadata
{
CachedAt = DateTimeOffset.UtcNow,
TreeSize = treeSize,
ContentHash = contentHash,
IsPartial = content.Length / HashSize < TileWidth,
Width = content.Length / HashSize
};
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(metaPath, metaJson, cancellationToken);
_logger.LogDebug(
"Cached tile {Origin}/{Level}/{Index} ({Bytes} bytes, hash: {Hash})",
origin, level, index, content.Length, contentHash[..16]);
}
finally
{
_writeLock.Release();
}
}
/// <summary>
/// Checks if a tile exists in the cache.
/// </summary>
public Task<bool> HasTileAsync(string origin, int level, long index, CancellationToken cancellationToken = default)
{
var tilePath = GetTilePath(origin, level, index);
return Task.FromResult(File.Exists(tilePath));
}
/// <summary>
/// Gets a checkpoint from the cache.
/// </summary>
public async Task<CachedCheckpoint?> GetCheckpointAsync(
string origin,
CancellationToken cancellationToken = default)
{
var checkpointPath = GetCheckpointPath(origin);
var metaPath = checkpointPath + ".meta.json";
if (!File.Exists(checkpointPath))
{
return null;
}
try
{
var content = await File.ReadAllTextAsync(checkpointPath, cancellationToken);
CachedCheckpoint? meta = null;
if (File.Exists(metaPath))
{
var metaJson = await File.ReadAllTextAsync(metaPath, cancellationToken);
meta = JsonSerializer.Deserialize<CachedCheckpoint>(metaJson);
}
// Check TTL
var cachedAt = meta?.CachedAt ?? File.GetCreationTimeUtc(checkpointPath);
var age = DateTimeOffset.UtcNow - cachedAt;
if (age.TotalMinutes > _options.Cache.CheckpointTtlMinutes)
{
_logger.LogDebug("Checkpoint for {Origin} is stale (age: {Age})", origin, age);
return null;
}
return new CachedCheckpoint
{
Origin = origin,
Content = content,
CachedAt = cachedAt,
TreeSize = meta?.TreeSize,
RootHash = meta?.RootHash
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cached checkpoint for {Origin}", origin);
return null;
}
}
/// <summary>
/// Stores a checkpoint in the cache.
/// </summary>
public async Task StoreCheckpointAsync(
string origin,
string content,
long? treeSize = null,
string? rootHash = null,
CancellationToken cancellationToken = default)
{
var checkpointPath = GetCheckpointPath(origin);
var metaPath = checkpointPath + ".meta.json";
var checkpointDir = Path.GetDirectoryName(checkpointPath)!;
await _writeLock.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(checkpointDir);
await File.WriteAllTextAsync(checkpointPath, content, cancellationToken);
var meta = new CachedCheckpoint
{
Origin = origin,
Content = content,
CachedAt = DateTimeOffset.UtcNow,
TreeSize = treeSize,
RootHash = rootHash
};
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(metaPath, metaJson, cancellationToken);
_logger.LogDebug("Cached checkpoint for {Origin} (tree size: {TreeSize})", origin, treeSize);
}
finally
{
_writeLock.Release();
}
}
/// <summary>
/// Gets cache statistics.
/// </summary>
public Task<TileCacheStats> GetStatsAsync(CancellationToken cancellationToken = default)
{
var basePath = _options.Cache.BasePath;
if (!Directory.Exists(basePath))
{
return Task.FromResult(new TileCacheStats());
}
var tileFiles = Directory.GetFiles(basePath, "*.tile", SearchOption.AllDirectories);
long totalBytes = 0;
int partialTiles = 0;
DateTimeOffset? oldestTile = null;
DateTimeOffset? newestTile = null;
foreach (var file in tileFiles)
{
var info = new FileInfo(file);
totalBytes += info.Length;
var creationTime = new DateTimeOffset(info.CreationTimeUtc, TimeSpan.Zero);
oldestTile = oldestTile == null ? creationTime : (creationTime < oldestTile ? creationTime : oldestTile);
newestTile = newestTile == null ? creationTime : (creationTime > newestTile ? creationTime : newestTile);
if (info.Length / HashSize < TileWidth)
{
partialTiles++;
}
}
return Task.FromResult(new TileCacheStats
{
TotalTiles = tileFiles.Length,
TotalBytes = totalBytes,
PartialTiles = partialTiles,
OldestTile = oldestTile,
NewestTile = newestTile,
MaxSizeBytes = _options.Cache.MaxSizeBytes
});
}
/// <summary>
/// Prunes tiles based on eviction policy.
/// </summary>
public async Task<int> PruneAsync(long targetSizeBytes, CancellationToken cancellationToken = default)
{
var stats = await GetStatsAsync(cancellationToken);
if (stats.TotalBytes <= targetSizeBytes)
{
return 0;
}
var bytesToFree = stats.TotalBytes - targetSizeBytes;
var tileFiles = Directory.GetFiles(_options.Cache.BasePath, "*.tile", SearchOption.AllDirectories)
.Select(f => new FileInfo(f))
.OrderBy(f => _accessTimes.GetValueOrDefault($"{f.Directory?.Parent?.Name}/{f.Directory?.Name}/{Path.GetFileNameWithoutExtension(f.Name)}", f.CreationTimeUtc))
.ToList();
long freedBytes = 0;
int prunedCount = 0;
await _writeLock.WaitAsync(cancellationToken);
try
{
foreach (var file in tileFiles)
{
if (freedBytes >= bytesToFree)
{
break;
}
try
{
var metaPath = Path.ChangeExtension(file.FullName, ".meta.json");
freedBytes += file.Length;
file.Delete();
if (File.Exists(metaPath))
{
File.Delete(metaPath);
}
prunedCount++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prune tile {File}", file.FullName);
}
}
}
finally
{
_writeLock.Release();
}
_logger.LogInformation("Pruned {Count} tiles, freed {Bytes} bytes", prunedCount, freedBytes);
return prunedCount;
}
private string GetOriginPath(string origin)
{
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(origin));
var hashHex = Convert.ToHexString(hash)[..16];
var readable = new string(origin
.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')
.Take(32)
.ToArray());
return Path.Combine(_options.Cache.BasePath, string.IsNullOrEmpty(readable) ? hashHex : $"{readable}_{hashHex}");
}
private string GetTilePath(string origin, int level, long index)
{
return Path.Combine(GetOriginPath(origin), "tiles", level.ToString(), $"{index}.tile");
}
private string GetMetaPath(string origin, int level, long index)
{
return Path.Combine(GetOriginPath(origin), "tiles", level.ToString(), $"{index}.meta.json");
}
private string GetCheckpointPath(string origin)
{
return Path.Combine(GetOriginPath(origin), "checkpoint");
}
private static string ComputeContentHash(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
public void Dispose()
{
_writeLock.Dispose();
}
private sealed record TileMetadata
{
public DateTimeOffset CachedAt { get; init; }
public long? TreeSize { get; init; }
public string? ContentHash { get; init; }
public bool IsPartial { get; init; }
public int Width { get; init; }
}
}
/// <summary>
/// Cached tile data.
/// </summary>
public sealed record CachedTileData
{
public required string Origin { get; init; }
public required int Level { get; init; }
public required long Index { get; init; }
public required byte[] Content { get; init; }
public required int Width { get; init; }
public required DateTimeOffset CachedAt { get; init; }
public long? TreeSize { get; init; }
public string? ContentHash { get; init; }
public bool IsPartial { get; init; }
}
/// <summary>
/// Cached checkpoint data.
/// </summary>
public sealed record CachedCheckpoint
{
public string Origin { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public DateTimeOffset CachedAt { get; init; }
public long? TreeSize { get; init; }
public string? RootHash { get; init; }
}
/// <summary>
/// Tile cache statistics.
/// </summary>
public sealed record TileCacheStats
{
public int TotalTiles { get; init; }
public long TotalBytes { get; init; }
public int PartialTiles { get; init; }
public DateTimeOffset? OldestTile { get; init; }
public DateTimeOffset? NewestTile { get; init; }
public long MaxSizeBytes { get; init; }
public double UsagePercent => MaxSizeBytes > 0 ? (double)TotalBytes / MaxSizeBytes * 100 : 0;
}

View File

@@ -0,0 +1,409 @@
// -----------------------------------------------------------------------------
// TileProxyService.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Core tile proxy service with request coalescing
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.TileProxy.Services;
/// <summary>
/// Core tile proxy service that fetches tiles from upstream and manages caching.
/// Supports request coalescing to avoid duplicate upstream requests.
/// </summary>
public sealed partial class TileProxyService : IDisposable
{
private readonly TileProxyOptions _options;
private readonly ContentAddressedTileStore _tileStore;
private readonly HttpClient _httpClient;
private readonly ILogger<TileProxyService> _logger;
private readonly ConcurrentDictionary<string, Task<byte[]>> _inflightTileRequests = new();
private readonly ConcurrentDictionary<string, Task<string>> _inflightCheckpointRequests = new();
private readonly SemaphoreSlim _coalesceGuard = new(1, 1);
// Metrics
private long _cacheHits;
private long _cacheMisses;
private long _upstreamRequests;
private long _upstreamErrors;
public TileProxyService(
IOptions<TileProxyOptions> options,
ContentAddressedTileStore tileStore,
HttpClient httpClient,
ILogger<TileProxyService> logger)
{
_options = options.Value;
_tileStore = tileStore;
_httpClient = httpClient;
_logger = logger;
_httpClient.Timeout = TimeSpan.FromSeconds(_options.Request.TimeoutSeconds);
}
/// <summary>
/// Gets a tile, fetching from upstream if not cached.
/// </summary>
public async Task<TileProxyResult> GetTileAsync(
int level,
long index,
int? partialWidth = null,
CancellationToken cancellationToken = default)
{
var origin = _options.Origin;
// Check cache first
var cached = await _tileStore.GetTileAsync(origin, level, index, cancellationToken);
if (cached != null)
{
// For partial tiles, check if we have enough data
if (partialWidth == null || cached.Width >= partialWidth)
{
Interlocked.Increment(ref _cacheHits);
_logger.LogDebug("Cache hit for tile {Level}/{Index}", level, index);
var content = cached.Content;
if (partialWidth.HasValue && cached.Width > partialWidth)
{
// Return only the requested portion
content = content[..(partialWidth.Value * 32)];
}
return new TileProxyResult
{
Success = true,
Content = content,
FromCache = true,
Level = level,
Index = index
};
}
}
Interlocked.Increment(ref _cacheMisses);
// Fetch from upstream (with coalescing)
var key = $"tile/{level}/{index}";
if (partialWidth.HasValue)
{
key += $".p/{partialWidth}";
}
try
{
byte[] tileContent;
if (_options.Request.CoalescingEnabled)
{
// Check for in-flight request
if (_inflightTileRequests.TryGetValue(key, out var existingTask))
{
_logger.LogDebug("Coalescing request for tile {Key}", key);
tileContent = await existingTask;
}
else
{
var fetchTask = FetchTileFromUpstreamAsync(level, index, partialWidth, cancellationToken);
if (_inflightTileRequests.TryAdd(key, fetchTask))
{
try
{
tileContent = await fetchTask;
}
finally
{
_inflightTileRequests.TryRemove(key, out _);
}
}
else
{
// Another thread added it; wait for that one
tileContent = await _inflightTileRequests[key];
}
}
}
else
{
tileContent = await FetchTileFromUpstreamAsync(level, index, partialWidth, cancellationToken);
}
// Cache the tile (only full tiles or if we got the full content)
if (partialWidth == null)
{
await _tileStore.StoreTileAsync(origin, level, index, tileContent, cancellationToken: cancellationToken);
}
return new TileProxyResult
{
Success = true,
Content = tileContent,
FromCache = false,
Level = level,
Index = index
};
}
catch (Exception ex)
{
Interlocked.Increment(ref _upstreamErrors);
_logger.LogWarning(ex, "Failed to fetch tile {Level}/{Index} from upstream", level, index);
// Return cached partial if available
if (cached != null)
{
_logger.LogInformation("Returning stale cached tile {Level}/{Index}", level, index);
return new TileProxyResult
{
Success = true,
Content = cached.Content,
FromCache = true,
Stale = true,
Level = level,
Index = index
};
}
return new TileProxyResult
{
Success = false,
Error = ex.Message,
Level = level,
Index = index
};
}
}
/// <summary>
/// Gets the current checkpoint.
/// </summary>
public async Task<CheckpointProxyResult> GetCheckpointAsync(CancellationToken cancellationToken = default)
{
var origin = _options.Origin;
// Check cache first (with TTL check)
var cached = await _tileStore.GetCheckpointAsync(origin, cancellationToken);
if (cached != null)
{
Interlocked.Increment(ref _cacheHits);
_logger.LogDebug("Cache hit for checkpoint");
return new CheckpointProxyResult
{
Success = true,
Content = cached.Content,
FromCache = true,
TreeSize = cached.TreeSize,
RootHash = cached.RootHash
};
}
Interlocked.Increment(ref _cacheMisses);
// Fetch from upstream
var key = "checkpoint";
try
{
string checkpointContent;
if (_options.Request.CoalescingEnabled)
{
if (_inflightCheckpointRequests.TryGetValue(key, out var existingTask))
{
_logger.LogDebug("Coalescing request for checkpoint");
checkpointContent = await existingTask;
}
else
{
var fetchTask = FetchCheckpointFromUpstreamAsync(cancellationToken);
if (_inflightCheckpointRequests.TryAdd(key, fetchTask))
{
try
{
checkpointContent = await fetchTask;
}
finally
{
_inflightCheckpointRequests.TryRemove(key, out _);
}
}
else
{
checkpointContent = await _inflightCheckpointRequests[key];
}
}
}
else
{
checkpointContent = await FetchCheckpointFromUpstreamAsync(cancellationToken);
}
// Parse checkpoint for tree size and root hash
var (treeSize, rootHash) = ParseCheckpoint(checkpointContent);
// Cache the checkpoint
await _tileStore.StoreCheckpointAsync(origin, checkpointContent, treeSize, rootHash, cancellationToken);
return new CheckpointProxyResult
{
Success = true,
Content = checkpointContent,
FromCache = false,
TreeSize = treeSize,
RootHash = rootHash
};
}
catch (Exception ex)
{
Interlocked.Increment(ref _upstreamErrors);
_logger.LogWarning(ex, "Failed to fetch checkpoint from upstream");
return new CheckpointProxyResult
{
Success = false,
Error = ex.Message
};
}
}
/// <summary>
/// Gets proxy metrics.
/// </summary>
public TileProxyMetrics GetMetrics()
{
return new TileProxyMetrics
{
CacheHits = _cacheHits,
CacheMisses = _cacheMisses,
UpstreamRequests = _upstreamRequests,
UpstreamErrors = _upstreamErrors,
InflightRequests = _inflightTileRequests.Count + _inflightCheckpointRequests.Count
};
}
private async Task<byte[]> FetchTileFromUpstreamAsync(
int level,
long index,
int? partialWidth,
CancellationToken cancellationToken)
{
var tileBaseUrl = _options.GetTileBaseUrl();
var url = $"{tileBaseUrl}/{level}/{index}";
if (partialWidth.HasValue)
{
url += $".p/{partialWidth}";
}
_logger.LogDebug("Fetching tile from upstream: {Url}", url);
Interlocked.Increment(ref _upstreamRequests);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
private async Task<string> FetchCheckpointFromUpstreamAsync(CancellationToken cancellationToken)
{
var checkpointUrl = $"{_options.UpstreamUrl.TrimEnd('/')}/checkpoint";
_logger.LogDebug("Fetching checkpoint from upstream: {Url}", checkpointUrl);
Interlocked.Increment(ref _upstreamRequests);
using var request = new HttpRequestMessage(HttpMethod.Get, checkpointUrl);
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
private static (long? treeSize, string? rootHash) ParseCheckpoint(string checkpoint)
{
// Checkpoint format (Sigstore):
// rekor.sigstore.dev - 1985497715
// 123456789
// abc123def456...
//
// — rekor.sigstore.dev wNI9ajBFAi...
var lines = checkpoint.Split('\n', StringSplitOptions.RemoveEmptyEntries);
long? treeSize = null;
string? rootHash = null;
if (lines.Length >= 2 && long.TryParse(lines[1].Trim(), out var size))
{
treeSize = size;
}
if (lines.Length >= 3)
{
var hashLine = lines[2].Trim();
if (HashLineRegex().IsMatch(hashLine))
{
rootHash = hashLine;
}
}
return (treeSize, rootHash);
}
[GeneratedRegex(@"^[a-fA-F0-9]{64}$")]
private static partial Regex HashLineRegex();
public void Dispose()
{
_coalesceGuard.Dispose();
}
}
/// <summary>
/// Result of a tile proxy request.
/// </summary>
public sealed record TileProxyResult
{
public bool Success { get; init; }
public byte[]? Content { get; init; }
public bool FromCache { get; init; }
public bool Stale { get; init; }
public string? Error { get; init; }
public int Level { get; init; }
public long Index { get; init; }
}
/// <summary>
/// Result of a checkpoint proxy request.
/// </summary>
public sealed record CheckpointProxyResult
{
public bool Success { get; init; }
public string? Content { get; init; }
public bool FromCache { get; init; }
public long? TreeSize { get; init; }
public string? RootHash { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Tile proxy metrics.
/// </summary>
public sealed record TileProxyMetrics
{
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public long UpstreamRequests { get; init; }
public long UpstreamErrors { get; init; }
public int InflightRequests { get; init; }
public double HitRate => CacheHits + CacheMisses > 0
? (double)CacheHits / (CacheHits + CacheMisses) * 100
: 0;
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
StellaOps.Attestor.TileProxy.csproj
Sprint: SPRINT_20260125_002_Attestor_trust_automation
Task: PROXY-002 - Implement tile-proxy service
Description: Tile caching proxy for Rekor transparency log
-->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Attestor.TileProxy</RootNamespace>
<AssemblyName>StellaOps.Attestor.TileProxy</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Attestor.TrustRepo\StellaOps.Attestor.TrustRepo.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,198 @@
// -----------------------------------------------------------------------------
// TileProxyOptions.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Configuration options for tile-proxy service
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Attestor.TileProxy;
/// <summary>
/// Configuration options for the tile-proxy service.
/// </summary>
public sealed record TileProxyOptions
{
/// <summary>
/// Upstream Rekor URL for tile fetching.
/// </summary>
[Required]
public string UpstreamUrl { get; init; } = "https://rekor.sigstore.dev";
/// <summary>
/// Base URL for tile API (if different from UpstreamUrl).
/// </summary>
public string? TileBaseUrl { get; init; }
/// <summary>
/// Origin identifier for the transparency log.
/// </summary>
public string Origin { get; init; } = "rekor.sigstore.dev - 1985497715";
/// <summary>
/// Cache configuration options.
/// </summary>
public TileProxyCacheOptions Cache { get; init; } = new();
/// <summary>
/// TUF integration options.
/// </summary>
public TileProxyTufOptions Tuf { get; init; } = new();
/// <summary>
/// Sync job options.
/// </summary>
public TileProxySyncOptions Sync { get; init; } = new();
/// <summary>
/// Request handling options.
/// </summary>
public TileProxyRequestOptions Request { get; init; } = new();
/// <summary>
/// Failover configuration.
/// </summary>
public TileProxyFailoverOptions Failover { get; init; } = new();
/// <summary>
/// Gets the effective tile base URL.
/// </summary>
public string GetTileBaseUrl()
{
if (!string.IsNullOrEmpty(TileBaseUrl))
{
return TileBaseUrl.TrimEnd('/');
}
var upstreamUri = new Uri(UpstreamUrl);
return new Uri(upstreamUri, "/tile/").ToString().TrimEnd('/');
}
}
/// <summary>
/// Cache configuration options.
/// </summary>
public sealed record TileProxyCacheOptions
{
/// <summary>
/// Base path for tile cache storage.
/// </summary>
public string BasePath { get; init; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"StellaOps", "TileProxy", "Tiles");
/// <summary>
/// Maximum cache size in gigabytes (0 = unlimited).
/// </summary>
public double MaxSizeGb { get; init; } = 10;
/// <summary>
/// Eviction policy: lru or time.
/// </summary>
public string EvictionPolicy { get; init; } = "lru";
/// <summary>
/// Checkpoint TTL in minutes (how long to cache checkpoints).
/// </summary>
public int CheckpointTtlMinutes { get; init; } = 5;
/// <summary>
/// Gets max cache size in bytes.
/// </summary>
public long MaxSizeBytes => (long)(MaxSizeGb * 1024 * 1024 * 1024);
}
/// <summary>
/// TUF integration options.
/// </summary>
public sealed record TileProxyTufOptions
{
/// <summary>
/// Whether TUF integration is enabled.
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// TUF repository URL.
/// </summary>
public string? Url { get; init; }
/// <summary>
/// Whether to validate checkpoint signatures.
/// </summary>
public bool ValidateCheckpointSignature { get; init; } = true;
}
/// <summary>
/// Sync job configuration.
/// </summary>
public sealed record TileProxySyncOptions
{
/// <summary>
/// Whether scheduled sync is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Cron schedule for sync job.
/// </summary>
public string Schedule { get; init; } = "0 */6 * * *";
/// <summary>
/// Number of recent entries to sync tiles for.
/// </summary>
public int Depth { get; init; } = 10000;
/// <summary>
/// Checkpoint refresh interval in minutes.
/// </summary>
public int CheckpointIntervalMinutes { get; init; } = 60;
}
/// <summary>
/// Request handling options.
/// </summary>
public sealed record TileProxyRequestOptions
{
/// <summary>
/// Whether request coalescing is enabled.
/// </summary>
public bool CoalescingEnabled { get; init; } = true;
/// <summary>
/// Maximum wait time for coalesced requests in milliseconds.
/// </summary>
public int CoalescingMaxWaitMs { get; init; } = 5000;
/// <summary>
/// Request timeout for upstream calls in seconds.
/// </summary>
public int TimeoutSeconds { get; init; } = 30;
}
/// <summary>
/// Failover configuration.
/// </summary>
public sealed record TileProxyFailoverOptions
{
/// <summary>
/// Whether failover is enabled.
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Number of retry attempts.
/// </summary>
public int RetryCount { get; init; } = 2;
/// <summary>
/// Delay between retries in milliseconds.
/// </summary>
public int RetryDelayMs { get; init; } = 1000;
/// <summary>
/// Additional upstream URLs for failover.
/// </summary>
public List<string> AdditionalUpstreams { get; init; } = [];
}

View File

@@ -0,0 +1,41 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
}
},
"tile_proxy": {
"upstream_url": "https://rekor.sigstore.dev",
"origin": "rekor.sigstore.dev - 1985497715",
"cache": {
"max_size_gb": 10,
"eviction_policy": "lru",
"checkpoint_ttl_minutes": 5
},
"tuf": {
"enabled": false,
"validate_checkpoint_signature": true
},
"sync": {
"enabled": true,
"schedule": "0 */6 * * *",
"depth": 10000,
"checkpoint_interval_minutes": 60
},
"request": {
"coalescing_enabled": true,
"coalescing_max_wait_ms": 5000,
"timeout_seconds": 30
},
"failover": {
"enabled": false,
"retry_count": 2,
"retry_delay_ms": 1000
}
}
}