fix tests. new product advisories enhancements
This commit is contained in:
61
src/Attestor/StellaOps.Attestor.TileProxy/Dockerfile
Normal file
61
src/Attestor/StellaOps.Attestor.TileProxy/Dockerfile
Normal 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"]
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
278
src/Attestor/StellaOps.Attestor.TileProxy/Jobs/TileSyncJob.cs
Normal file
278
src/Attestor/StellaOps.Attestor.TileProxy/Jobs/TileSyncJob.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
137
src/Attestor/StellaOps.Attestor.TileProxy/Program.cs
Normal file
137
src/Attestor/StellaOps.Attestor.TileProxy/Program.cs
Normal 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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
198
src/Attestor/StellaOps.Attestor.TileProxy/TileProxyOptions.cs
Normal file
198
src/Attestor/StellaOps.Attestor.TileProxy/TileProxyOptions.cs
Normal 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; } = [];
|
||||
}
|
||||
41
src/Attestor/StellaOps.Attestor.TileProxy/appsettings.json
Normal file
41
src/Attestor/StellaOps.Attestor.TileProxy/appsettings.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user