up
This commit is contained in:
80
src/Symbols/AGENTS.md
Normal file
80
src/Symbols/AGENTS.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Symbols Module - Agent Instructions
|
||||
|
||||
## Module Overview
|
||||
The Symbols module provides debug symbol storage, resolution, and distribution for binary analysis and call-graph construction. It supports multi-tenant symbol management with DSSE-signed manifests and CAS-backed blob storage.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Projects
|
||||
- **StellaOps.Symbols.Core** - Core abstractions, models, and interfaces
|
||||
- **StellaOps.Symbols.Infrastructure** - In-memory and production implementations
|
||||
- **StellaOps.Symbols.Server** - REST API for symbol management
|
||||
- **StellaOps.Symbols.Client** - Client SDK for Scanner/runtime integration
|
||||
- **StellaOps.Symbols.Ingestor.Cli** - CLI tool for symbol ingestion
|
||||
|
||||
### Key Abstractions
|
||||
- `ISymbolRepository` - Store and query symbol manifests
|
||||
- `ISymbolBlobStore` - CAS-backed blob storage for symbol files
|
||||
- `ISymbolResolver` - Address-to-symbol resolution service
|
||||
|
||||
### Data Model
|
||||
- `SymbolManifest` - Debug symbol metadata with tenant isolation
|
||||
- `SymbolEntry` - Individual symbol (function, variable) with address/name
|
||||
- `SourceMapping` - Source file mappings for debugging
|
||||
|
||||
## API Conventions
|
||||
|
||||
### Endpoints
|
||||
- `POST /v1/symbols/manifests` - Upload symbol manifest
|
||||
- `GET /v1/symbols/manifests/{id}` - Get manifest by ID
|
||||
- `GET /v1/symbols/manifests` - Query manifests with filters
|
||||
- `POST /v1/symbols/resolve` - Resolve addresses to symbols
|
||||
- `GET /v1/symbols/by-debug-id/{debugId}` - Get manifests by debug ID
|
||||
- `GET /health` - Health check (anonymous)
|
||||
|
||||
### Headers
|
||||
- `X-Stella-Tenant` - Required tenant ID header
|
||||
- Standard Bearer authentication
|
||||
|
||||
### Identifiers
|
||||
- `ManifestId` - BLAKE3 hash of manifest content
|
||||
- `DebugId` - Build-ID (ELF) or PDB GUID
|
||||
- `CodeId` - GNU build-id or PE checksum
|
||||
|
||||
## Storage
|
||||
|
||||
### CAS Paths
|
||||
- Manifests: `cas://symbols/{tenant}/{debug_id}/{manifest_hash}`
|
||||
- Blobs: `cas://symbols/{tenant}/{debug_id}/{content_hash}`
|
||||
|
||||
### Indexing
|
||||
- Primary: `manifest_id`
|
||||
- Secondary: `debug_id`, `code_id`, `tenant_id`
|
||||
- Composite: `(tenant_id, debug_id, platform)`
|
||||
|
||||
## DSSE Integration
|
||||
- Manifests may be DSSE-signed with `stella.ops/symbols@v1` predicate
|
||||
- Rekor log index stored in manifest for transparency
|
||||
- DSSE digest used for verification
|
||||
|
||||
## Development
|
||||
|
||||
### In-Memory Mode
|
||||
For development, use `AddSymbolsInMemory()` which registers in-memory implementations of all services.
|
||||
|
||||
### Testing
|
||||
- Unit tests under `__Tests/StellaOps.Symbols.Tests`
|
||||
- Integration tests require Symbols Server running
|
||||
- Fixtures in `tests/Symbols/fixtures/`
|
||||
|
||||
## Sprint References
|
||||
- SYMS-SERVER-401-011 - Server bootstrap
|
||||
- SYMS-CLIENT-401-012 - Client SDK
|
||||
- SYMS-INGEST-401-013 - Ingestor CLI
|
||||
- SYMS-BUNDLE-401-014 - Offline bundles
|
||||
|
||||
## Key Decisions
|
||||
- BLAKE3 for content hashing (SHA256 fallback)
|
||||
- Deterministic debug ID indexing
|
||||
- Multi-tenant isolation via header
|
||||
- In-memory default for development
|
||||
321
src/Symbols/StellaOps.Symbols.Client/DiskLruCache.cs
Normal file
321
src/Symbols/StellaOps.Symbols.Client/DiskLruCache.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// LRU disk cache for symbol data with size-based eviction.
|
||||
/// </summary>
|
||||
public sealed class DiskLruCache : IDisposable
|
||||
{
|
||||
private readonly string _cachePath;
|
||||
private readonly long _maxSizeBytes;
|
||||
private readonly ILogger<DiskLruCache>? _logger;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _index = new();
|
||||
private readonly SemaphoreSlim _evictionLock = new(1, 1);
|
||||
private long _currentSizeBytes;
|
||||
private bool _disposed;
|
||||
|
||||
private const string IndexFileName = ".cache-index.json";
|
||||
|
||||
public DiskLruCache(string cachePath, long maxSizeBytes, ILogger<DiskLruCache>? logger = null)
|
||||
{
|
||||
_cachePath = cachePath ?? throw new ArgumentNullException(nameof(cachePath));
|
||||
_maxSizeBytes = maxSizeBytes > 0 ? maxSizeBytes : throw new ArgumentOutOfRangeException(nameof(maxSizeBytes));
|
||||
_logger = logger;
|
||||
|
||||
Directory.CreateDirectory(_cachePath);
|
||||
LoadIndex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached item by key.
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var hash = ComputeKeyHash(key);
|
||||
if (!_index.TryGetValue(hash, out var entry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var filePath = GetFilePath(hash);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
_index.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update access time (LRU tracking)
|
||||
entry.LastAccess = DateTimeOffset.UtcNow;
|
||||
_index[hash] = entry;
|
||||
|
||||
_logger?.LogDebug("Cache hit for key {Key}", key);
|
||||
return data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to read cached file for key {Key}", key);
|
||||
_index.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores an item in the cache.
|
||||
/// </summary>
|
||||
public async Task SetAsync(string key, byte[] data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (data.Length > _maxSizeBytes)
|
||||
{
|
||||
_logger?.LogWarning("Data size {Size} exceeds max cache size {MaxSize}, skipping cache", data.Length, _maxSizeBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
var hash = ComputeKeyHash(key);
|
||||
var filePath = GetFilePath(hash);
|
||||
|
||||
// Ensure enough space
|
||||
await EnsureSpaceAsync(data.Length, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(filePath, data, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entry = new CacheEntry
|
||||
{
|
||||
Key = key,
|
||||
Hash = hash,
|
||||
Size = data.Length,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastAccess = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (_index.TryGetValue(hash, out var existing))
|
||||
{
|
||||
Interlocked.Add(ref _currentSizeBytes, -existing.Size);
|
||||
}
|
||||
|
||||
_index[hash] = entry;
|
||||
Interlocked.Add(ref _currentSizeBytes, data.Length);
|
||||
|
||||
_logger?.LogDebug("Cached {Size} bytes for key {Key}", data.Length, key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to cache data for key {Key}", key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the cache.
|
||||
/// </summary>
|
||||
public Task<bool> RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var hash = ComputeKeyHash(key);
|
||||
if (!_index.TryRemove(hash, out var entry))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var filePath = GetFilePath(hash);
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
Interlocked.Add(ref _currentSizeBytes, -entry.Size);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to remove cached file for key {Key}", key);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached items.
|
||||
/// </summary>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
foreach (var entry in _index.Values)
|
||||
{
|
||||
var filePath = GetFilePath(entry.Hash);
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
_index.Clear();
|
||||
Interlocked.Exchange(ref _currentSizeBytes, 0);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current cache statistics.
|
||||
/// </summary>
|
||||
public CacheStats GetStats()
|
||||
{
|
||||
return new CacheStats
|
||||
{
|
||||
ItemCount = _index.Count,
|
||||
CurrentSizeBytes = Interlocked.Read(ref _currentSizeBytes),
|
||||
MaxSizeBytes = _maxSizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnsureSpaceAsync(long requiredBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Read(ref _currentSizeBytes) + requiredBytes <= _maxSizeBytes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _evictionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Evict LRU entries until we have enough space
|
||||
var targetSize = _maxSizeBytes - requiredBytes;
|
||||
var entries = _index.Values
|
||||
.OrderBy(e => e.LastAccess)
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (Interlocked.Read(ref _currentSizeBytes) <= targetSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var filePath = GetFilePath(entry.Hash);
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
_index.TryRemove(entry.Hash, out _);
|
||||
Interlocked.Add(ref _currentSizeBytes, -entry.Size);
|
||||
_logger?.LogDebug("Evicted cache entry {Key} ({Size} bytes)", entry.Key, entry.Size);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to evict cache entry {Key}", entry.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evictionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadIndex()
|
||||
{
|
||||
var indexPath = Path.Combine(_cachePath, IndexFileName);
|
||||
if (!File.Exists(indexPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(indexPath);
|
||||
var entries = JsonSerializer.Deserialize<List<CacheEntry>>(json);
|
||||
if (entries is not null)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var filePath = GetFilePath(entry.Hash);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
_index[entry.Hash] = entry;
|
||||
Interlocked.Add(ref _currentSizeBytes, entry.Size);
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger?.LogDebug("Loaded {Count} cache entries from index", _index.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to load cache index, starting fresh");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveIndex()
|
||||
{
|
||||
var indexPath = Path.Combine(_cachePath, IndexFileName);
|
||||
try
|
||||
{
|
||||
var entries = _index.Values.ToList();
|
||||
var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = false });
|
||||
File.WriteAllText(indexPath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to save cache index");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetFilePath(string hash) => Path.Combine(_cachePath, hash);
|
||||
|
||||
private static string ComputeKeyHash(string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
SaveIndex();
|
||||
_evictionLock.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
public long Size { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastAccess { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record CacheStats
|
||||
{
|
||||
public int ItemCount { get; init; }
|
||||
public long CurrentSizeBytes { get; init; }
|
||||
public long MaxSizeBytes { get; init; }
|
||||
public double UsagePercent => MaxSizeBytes > 0 ? (double)CurrentSizeBytes / MaxSizeBytes * 100 : 0;
|
||||
}
|
||||
142
src/Symbols/StellaOps.Symbols.Client/ISymbolsClient.cs
Normal file
142
src/Symbols/StellaOps.Symbols.Client/ISymbolsClient.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for the Symbols service.
|
||||
/// </summary>
|
||||
public interface ISymbolsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a symbol manifest to the server.
|
||||
/// </summary>
|
||||
Task<SymbolManifestUploadResult> UploadManifestAsync(
|
||||
SymbolManifest manifest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a manifest by ID.
|
||||
/// </summary>
|
||||
Task<SymbolManifest?> GetManifestAsync(
|
||||
string manifestId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets manifests by debug ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves addresses to symbols.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolResolutionResult>> ResolveAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single address to a symbol.
|
||||
/// </summary>
|
||||
Task<SymbolResolutionResult?> ResolveAddressAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries manifests with filters.
|
||||
/// </summary>
|
||||
Task<SymbolManifestQueryResult> QueryManifestsAsync(
|
||||
SymbolManifestQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets service health status.
|
||||
/// </summary>
|
||||
Task<SymbolsHealthStatus> GetHealthAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of manifest upload.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestUploadResult
|
||||
{
|
||||
public required string ManifestId { get; init; }
|
||||
public required string DebugId { get; init; }
|
||||
public required string BinaryName { get; init; }
|
||||
public string? BlobUri { get; init; }
|
||||
public required int SymbolCount { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of symbol resolution.
|
||||
/// </summary>
|
||||
public sealed record SymbolResolutionResult
|
||||
{
|
||||
public required ulong Address { get; init; }
|
||||
public required bool Found { get; init; }
|
||||
public string? MangledName { get; init; }
|
||||
public string? DemangledName { get; init; }
|
||||
public ulong Offset { get; init; }
|
||||
public string? SourceFile { get; init; }
|
||||
public int? SourceLine { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for manifest search.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestQuery
|
||||
{
|
||||
public string? DebugId { get; init; }
|
||||
public string? CodeId { get; init; }
|
||||
public string? BinaryName { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
public BinaryFormat? Format { get; init; }
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
public bool? HasDsse { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of manifest query.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestQueryResult
|
||||
{
|
||||
public required IReadOnlyList<SymbolManifestSummary> Manifests { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a symbol manifest.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestSummary
|
||||
{
|
||||
public required string ManifestId { get; init; }
|
||||
public required string DebugId { get; init; }
|
||||
public string? CodeId { get; init; }
|
||||
public required string BinaryName { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
public required BinaryFormat Format { get; init; }
|
||||
public required int SymbolCount { get; init; }
|
||||
public required bool HasDsse { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbols service health status.
|
||||
/// </summary>
|
||||
public sealed record SymbolsHealthStatus
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public long? TotalManifests { get; init; }
|
||||
public long? TotalSymbols { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for Symbols client.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Symbols client with default configuration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsClient(this IServiceCollection services)
|
||||
{
|
||||
return services.AddSymbolsClient(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the Symbols client with configuration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsClient(
|
||||
this IServiceCollection services,
|
||||
Action<SymbolsClientOptions> configure)
|
||||
{
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddHttpClient<ISymbolsClient, SymbolsClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SymbolsClientOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the Symbols client with a named HTTP client.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsClient(
|
||||
this IServiceCollection services,
|
||||
string httpClientName,
|
||||
Action<SymbolsClientOptions> configure)
|
||||
{
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddHttpClient<ISymbolsClient, SymbolsClient>(httpClientName, (sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SymbolsClientOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
434
src/Symbols/StellaOps.Symbols.Client/SymbolsClient.cs
Normal file
434
src/Symbols/StellaOps.Symbols.Client/SymbolsClient.cs
Normal file
@@ -0,0 +1,434 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the Symbols service.
|
||||
/// </summary>
|
||||
public sealed class SymbolsClient : ISymbolsClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SymbolsClientOptions _options;
|
||||
private readonly DiskLruCache? _cache;
|
||||
private readonly ILogger<SymbolsClient>? _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private bool _disposed;
|
||||
|
||||
private const string TenantHeader = "X-Tenant-Id";
|
||||
|
||||
public SymbolsClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SymbolsClientOptions> options,
|
||||
ILogger<SymbolsClient>? logger = null,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
if (_options.EnableDiskCache)
|
||||
{
|
||||
var cacheLogger = loggerFactory?.CreateLogger<DiskLruCache>();
|
||||
_cache = new DiskLruCache(_options.CachePath, _options.MaxCacheSizeBytes, cacheLogger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolManifestUploadResult> UploadManifestAsync(
|
||||
SymbolManifest manifest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var request = new UploadManifestRequest(
|
||||
DebugId: manifest.DebugId,
|
||||
BinaryName: manifest.BinaryName,
|
||||
CodeId: manifest.CodeId,
|
||||
Platform: manifest.Platform,
|
||||
Format: manifest.Format,
|
||||
Symbols: manifest.Symbols.Select(s => new SymbolEntryRequest(
|
||||
Address: s.Address,
|
||||
Size: s.Size,
|
||||
MangledName: s.MangledName,
|
||||
DemangledName: s.DemangledName,
|
||||
Type: s.Type,
|
||||
Binding: s.Binding,
|
||||
SourceFile: s.SourceFile,
|
||||
SourceLine: s.SourceLine,
|
||||
ContentHash: s.ContentHash)).ToList(),
|
||||
SourceMappings: manifest.SourceMappings?.Select(m => new SourceMappingRequest(
|
||||
CompiledPath: m.CompiledPath,
|
||||
SourcePath: m.SourcePath,
|
||||
ContentHash: m.ContentHash)).ToList());
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/symbols/manifests");
|
||||
AddTenantHeader(httpRequest);
|
||||
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<UploadManifestResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SymbolManifestUploadResult
|
||||
{
|
||||
ManifestId = result!.ManifestId,
|
||||
DebugId = result.DebugId,
|
||||
BinaryName = result.BinaryName,
|
||||
BlobUri = result.BlobUri,
|
||||
SymbolCount = result.SymbolCount,
|
||||
CreatedAt = result.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolManifest?> GetManifestAsync(
|
||||
string manifestId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/v1/symbols/manifests/{Uri.EscapeDataString(manifestId)}");
|
||||
AddTenantHeader(request);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var detail = await response.Content.ReadFromJsonAsync<ManifestDetailResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return MapToManifest(detail!);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/v1/symbols/by-debug-id/{Uri.EscapeDataString(debugId)}");
|
||||
AddTenantHeader(request);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var summaries = await response.Content.ReadFromJsonAsync<List<ManifestSummaryResponse>>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Note: This returns summaries, not full manifests. For full manifests, call GetManifestAsync for each.
|
||||
return summaries!.Select(s => new SymbolManifest
|
||||
{
|
||||
ManifestId = s.ManifestId,
|
||||
DebugId = s.DebugId,
|
||||
CodeId = s.CodeId,
|
||||
BinaryName = s.BinaryName,
|
||||
Platform = s.Platform,
|
||||
Format = s.Format,
|
||||
TenantId = _options.TenantId ?? string.Empty,
|
||||
Symbols = [],
|
||||
CreatedAt = s.CreatedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolResolutionResult>> ResolveAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var addressList = addresses.ToList();
|
||||
|
||||
// Check cache first
|
||||
if (_cache is not null)
|
||||
{
|
||||
var cacheKey = $"resolve:{debugId}:{string.Join(",", addressList)}";
|
||||
var cached = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cached is not null)
|
||||
{
|
||||
_logger?.LogDebug("Cache hit for resolution batch");
|
||||
return JsonSerializer.Deserialize<List<SymbolResolutionResult>>(cached, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
|
||||
var requestBody = new ResolveRequest(debugId, addressList);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/symbols/resolve");
|
||||
AddTenantHeader(request);
|
||||
request.Content = JsonContent.Create(requestBody, options: _jsonOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var resolveResponse = await response.Content.ReadFromJsonAsync<ResolveResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = resolveResponse!.Resolutions.Select(r => new SymbolResolutionResult
|
||||
{
|
||||
Address = r.Address,
|
||||
Found = r.Found,
|
||||
MangledName = r.MangledName,
|
||||
DemangledName = r.DemangledName,
|
||||
Offset = r.Offset,
|
||||
SourceFile = r.SourceFile,
|
||||
SourceLine = r.SourceLine,
|
||||
Confidence = r.Confidence
|
||||
}).ToList();
|
||||
|
||||
// Cache result
|
||||
if (_cache is not null)
|
||||
{
|
||||
var cacheKey = $"resolve:{debugId}:{string.Join(",", addressList)}";
|
||||
var data = JsonSerializer.SerializeToUtf8Bytes(results, _jsonOptions);
|
||||
await _cache.SetAsync(cacheKey, data, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolResolutionResult?> ResolveAddressAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = await ResolveAsync(debugId, [address], cancellationToken).ConfigureAwait(false);
|
||||
return results.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolManifestQueryResult> QueryManifestsAsync(
|
||||
SymbolManifestQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrEmpty(query.DebugId)) queryParams.Add($"debugId={Uri.EscapeDataString(query.DebugId)}");
|
||||
if (!string.IsNullOrEmpty(query.CodeId)) queryParams.Add($"codeId={Uri.EscapeDataString(query.CodeId)}");
|
||||
if (!string.IsNullOrEmpty(query.BinaryName)) queryParams.Add($"binaryName={Uri.EscapeDataString(query.BinaryName)}");
|
||||
if (!string.IsNullOrEmpty(query.Platform)) queryParams.Add($"platform={Uri.EscapeDataString(query.Platform)}");
|
||||
if (query.Format.HasValue) queryParams.Add($"format={query.Format.Value}");
|
||||
if (query.CreatedAfter.HasValue) queryParams.Add($"createdAfter={query.CreatedAfter.Value:O}");
|
||||
if (query.CreatedBefore.HasValue) queryParams.Add($"createdBefore={query.CreatedBefore.Value:O}");
|
||||
if (query.HasDsse.HasValue) queryParams.Add($"hasDsse={query.HasDsse.Value}");
|
||||
queryParams.Add($"offset={query.Offset}");
|
||||
queryParams.Add($"limit={query.Limit}");
|
||||
|
||||
var url = "/v1/symbols/manifests?" + string.Join("&", queryParams);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
AddTenantHeader(request);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var listResponse = await response.Content.ReadFromJsonAsync<ManifestListResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SymbolManifestQueryResult
|
||||
{
|
||||
Manifests = listResponse!.Manifests.Select(m => new SymbolManifestSummary
|
||||
{
|
||||
ManifestId = m.ManifestId,
|
||||
DebugId = m.DebugId,
|
||||
CodeId = m.CodeId,
|
||||
BinaryName = m.BinaryName,
|
||||
Platform = m.Platform,
|
||||
Format = m.Format,
|
||||
SymbolCount = m.SymbolCount,
|
||||
HasDsse = m.HasDsse,
|
||||
CreatedAt = m.CreatedAt
|
||||
}).ToList(),
|
||||
TotalCount = listResponse.TotalCount,
|
||||
Offset = listResponse.Offset,
|
||||
Limit = listResponse.Limit
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolsHealthStatus> GetHealthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var response = await _httpClient.GetAsync("/health", cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var health = await response.Content.ReadFromJsonAsync<HealthResponse>(_jsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SymbolsHealthStatus
|
||||
{
|
||||
Status = health!.Status,
|
||||
Version = health.Version,
|
||||
Timestamp = health.Timestamp,
|
||||
TotalManifests = health.Metrics?.TotalManifests,
|
||||
TotalSymbols = health.Metrics?.TotalSymbols
|
||||
};
|
||||
}
|
||||
|
||||
private void AddTenantHeader(HttpRequestMessage request)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_options.TenantId))
|
||||
{
|
||||
request.Headers.Add(TenantHeader, _options.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private static SymbolManifest MapToManifest(ManifestDetailResponse detail)
|
||||
{
|
||||
return new SymbolManifest
|
||||
{
|
||||
ManifestId = detail.ManifestId,
|
||||
DebugId = detail.DebugId,
|
||||
CodeId = detail.CodeId,
|
||||
BinaryName = detail.BinaryName,
|
||||
Platform = detail.Platform,
|
||||
Format = detail.Format,
|
||||
TenantId = detail.TenantId,
|
||||
BlobUri = detail.BlobUri,
|
||||
DsseDigest = detail.DsseDigest,
|
||||
RekorLogIndex = detail.RekorLogIndex,
|
||||
Symbols = detail.Symbols.Select(s => new SymbolEntry
|
||||
{
|
||||
Address = s.Address,
|
||||
Size = s.Size,
|
||||
MangledName = s.MangledName,
|
||||
DemangledName = s.DemangledName,
|
||||
Type = s.Type,
|
||||
Binding = s.Binding,
|
||||
SourceFile = s.SourceFile,
|
||||
SourceLine = s.SourceLine,
|
||||
ContentHash = s.ContentHash
|
||||
}).ToList(),
|
||||
SourceMappings = detail.SourceMappings?.Select(m => new SourceMapping
|
||||
{
|
||||
CompiledPath = m.CompiledPath,
|
||||
SourcePath = m.SourcePath,
|
||||
ContentHash = m.ContentHash
|
||||
}).ToList(),
|
||||
CreatedAt = detail.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cache?.Dispose();
|
||||
}
|
||||
|
||||
// Request/Response DTOs for serialization
|
||||
private sealed record UploadManifestRequest(
|
||||
string DebugId,
|
||||
string BinaryName,
|
||||
string? CodeId,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
IReadOnlyList<SymbolEntryRequest> Symbols,
|
||||
IReadOnlyList<SourceMappingRequest>? SourceMappings);
|
||||
|
||||
private sealed record SymbolEntryRequest(
|
||||
ulong Address,
|
||||
ulong Size,
|
||||
string MangledName,
|
||||
string? DemangledName,
|
||||
SymbolType Type,
|
||||
SymbolBinding Binding,
|
||||
string? SourceFile,
|
||||
int? SourceLine,
|
||||
string? ContentHash);
|
||||
|
||||
private sealed record SourceMappingRequest(
|
||||
string CompiledPath,
|
||||
string SourcePath,
|
||||
string? ContentHash);
|
||||
|
||||
private sealed record UploadManifestResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string BinaryName,
|
||||
string? BlobUri,
|
||||
int SymbolCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ManifestDetailResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string? CodeId,
|
||||
string BinaryName,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
string TenantId,
|
||||
string? BlobUri,
|
||||
string? DsseDigest,
|
||||
long? RekorLogIndex,
|
||||
int SymbolCount,
|
||||
IReadOnlyList<SymbolEntryRequest> Symbols,
|
||||
IReadOnlyList<SourceMappingRequest>? SourceMappings,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ManifestSummaryResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string? CodeId,
|
||||
string BinaryName,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
int SymbolCount,
|
||||
bool HasDsse,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ManifestListResponse(
|
||||
IReadOnlyList<ManifestSummaryResponse> Manifests,
|
||||
int TotalCount,
|
||||
int Offset,
|
||||
int Limit);
|
||||
|
||||
private sealed record ResolveRequest(string DebugId, IReadOnlyList<ulong> Addresses);
|
||||
|
||||
private sealed record ResolveResponse(string DebugId, IReadOnlyList<ResolutionDto> Resolutions);
|
||||
|
||||
private sealed record ResolutionDto(
|
||||
ulong Address,
|
||||
bool Found,
|
||||
string? MangledName,
|
||||
string? DemangledName,
|
||||
ulong Offset,
|
||||
string? SourceFile,
|
||||
int? SourceLine,
|
||||
double Confidence);
|
||||
|
||||
private sealed record HealthResponse(
|
||||
string Status,
|
||||
string Version,
|
||||
DateTimeOffset Timestamp,
|
||||
HealthMetrics? Metrics);
|
||||
|
||||
private sealed record HealthMetrics(
|
||||
long TotalManifests,
|
||||
long TotalSymbols,
|
||||
long TotalBlobBytes);
|
||||
}
|
||||
44
src/Symbols/StellaOps.Symbols.Client/SymbolsClientOptions.cs
Normal file
44
src/Symbols/StellaOps.Symbols.Client/SymbolsClientOptions.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Symbols.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Symbols client.
|
||||
/// </summary>
|
||||
public sealed class SymbolsClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL of the Symbols server.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "http://localhost:5270";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for HTTP requests in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts for transient failures.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Enable local disk cache for resolved symbols.
|
||||
/// </summary>
|
||||
public bool EnableDiskCache { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the disk cache directory.
|
||||
/// </summary>
|
||||
public string CachePath { get; set; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"StellaOps", "SymbolsCache");
|
||||
|
||||
/// <summary>
|
||||
/// Maximum size of disk cache in bytes (default 1GB).
|
||||
/// </summary>
|
||||
public long MaxCacheSizeBytes { get; set; } = 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID header value for multi-tenant requests.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Symbols.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Blob store for symbol files (PDBs, DWARF, etc.).
|
||||
/// </summary>
|
||||
public interface ISymbolBlobStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a symbol blob and returns its CAS URI.
|
||||
/// </summary>
|
||||
Task<SymbolBlobUploadResult> UploadAsync(
|
||||
Stream content,
|
||||
string tenantId,
|
||||
string debugId,
|
||||
string? fileName = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a symbol blob by CAS URI.
|
||||
/// </summary>
|
||||
Task<Stream?> DownloadAsync(
|
||||
string blobUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a blob exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(
|
||||
string blobUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets blob metadata without downloading content.
|
||||
/// </summary>
|
||||
Task<SymbolBlobMetadata?> GetMetadataAsync(
|
||||
string blobUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a blob (requires admin).
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
string blobUri,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of blob upload operation.
|
||||
/// </summary>
|
||||
public sealed record SymbolBlobUploadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// CAS URI for the uploaded blob.
|
||||
/// </summary>
|
||||
public required string BlobUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the content.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this was a duplicate (already existed).
|
||||
/// </summary>
|
||||
public bool IsDuplicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a stored blob.
|
||||
/// </summary>
|
||||
public sealed record SymbolBlobMetadata
|
||||
{
|
||||
public required string BlobUri { get; init; }
|
||||
public required string ContentHash { get; init; }
|
||||
public required long Size { get; init; }
|
||||
public required string ContentType { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? DebugId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for storing and retrieving symbol manifests.
|
||||
/// </summary>
|
||||
public interface ISymbolRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a symbol manifest.
|
||||
/// </summary>
|
||||
Task<string> StoreManifestAsync(SymbolManifest manifest, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a manifest by ID.
|
||||
/// </summary>
|
||||
Task<SymbolManifest?> GetManifestAsync(string manifestId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves manifests by debug ID (may return multiple for different platforms).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves manifests by code ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolManifest>> GetManifestsByCodeIdAsync(
|
||||
string codeId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries manifests with filters.
|
||||
/// </summary>
|
||||
Task<SymbolQueryResult> QueryManifestsAsync(
|
||||
SymbolQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a manifest exists.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string manifestId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a manifest (soft delete with tombstone).
|
||||
/// </summary>
|
||||
Task<bool> DeleteManifestAsync(string manifestId, string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for symbol manifests.
|
||||
/// </summary>
|
||||
public sealed record SymbolQuery
|
||||
{
|
||||
public string? TenantId { get; init; }
|
||||
public string? DebugId { get; init; }
|
||||
public string? CodeId { get; init; }
|
||||
public string? BinaryName { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
public BinaryFormat? Format { get; init; }
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
public bool? HasDsse { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public int Offset { get; init; } = 0;
|
||||
public string SortBy { get; init; } = "created_at";
|
||||
public bool SortDescending { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a symbol query.
|
||||
/// </summary>
|
||||
public sealed record SymbolQueryResult
|
||||
{
|
||||
public required IReadOnlyList<SymbolManifest> Manifests { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves symbols for addresses in binaries.
|
||||
/// </summary>
|
||||
public interface ISymbolResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a symbol at the given address.
|
||||
/// </summary>
|
||||
Task<SymbolResolution?> ResolveAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch resolve multiple addresses.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolResolution>> ResolveBatchAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all symbols for a binary.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SymbolEntry>> GetAllSymbolsAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
SymbolType? typeFilter = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of symbol resolution.
|
||||
/// </summary>
|
||||
public sealed record SymbolResolution
|
||||
{
|
||||
/// <summary>
|
||||
/// The requested address.
|
||||
/// </summary>
|
||||
public required ulong Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if a symbol was found.
|
||||
/// </summary>
|
||||
public required bool Found { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The symbol entry if found.
|
||||
/// </summary>
|
||||
public SymbolEntry? Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offset within the symbol (address - symbol.Address).
|
||||
/// </summary>
|
||||
public ulong Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Debug ID used for resolution.
|
||||
/// </summary>
|
||||
public required string DebugId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest ID that provided the symbol.
|
||||
/// </summary>
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution confidence (1.0 = exact match).
|
||||
/// </summary>
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
}
|
||||
185
src/Symbols/StellaOps.Symbols.Core/Models/SymbolManifest.cs
Normal file
185
src/Symbols/StellaOps.Symbols.Core/Models/SymbolManifest.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
namespace StellaOps.Symbols.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a symbol manifest containing debug symbols for a binary artifact.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this manifest (BLAKE3 hash of content).
|
||||
/// </summary>
|
||||
public required string ManifestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Debug ID (build-id or PDB GUID) for lookup.
|
||||
/// </summary>
|
||||
public required string DebugId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Code ID for the binary (GNU build-id, PE checksum, etc.).
|
||||
/// </summary>
|
||||
public string? CodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original binary name.
|
||||
/// </summary>
|
||||
public required string BinaryName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform/architecture (e.g., linux-x64, win-x64).
|
||||
/// </summary>
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary format (ELF, PE, Mach-O).
|
||||
/// </summary>
|
||||
public BinaryFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol entries in the manifest.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<SymbolEntry> Symbols { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file mappings if available.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SourceMapping>? SourceMappings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CAS URI where the symbol blob is stored.
|
||||
/// </summary>
|
||||
public string? BlobUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope digest if signed.
|
||||
/// </summary>
|
||||
public string? DsseDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if published.
|
||||
/// </summary>
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Created timestamp (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm used for ManifestId.
|
||||
/// </summary>
|
||||
public string HashAlgorithm { get; init; } = "blake3";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual symbol entry in a manifest.
|
||||
/// </summary>
|
||||
public sealed record SymbolEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol address (virtual address or offset).
|
||||
/// </summary>
|
||||
public required ulong Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol size in bytes.
|
||||
/// </summary>
|
||||
public ulong Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mangled symbol name.
|
||||
/// </summary>
|
||||
public required string MangledName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Demangled/human-readable name.
|
||||
/// </summary>
|
||||
public string? DemangledName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol type (function, variable, etc.).
|
||||
/// </summary>
|
||||
public SymbolType Type { get; init; } = SymbolType.Function;
|
||||
|
||||
/// <summary>
|
||||
/// Symbol binding (local, global, weak).
|
||||
/// </summary>
|
||||
public SymbolBinding Binding { get; init; } = SymbolBinding.Global;
|
||||
|
||||
/// <summary>
|
||||
/// Source file path if available.
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source line number if available.
|
||||
/// </summary>
|
||||
public int? SourceLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the symbol content for deduplication.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source file mapping for source-level debugging.
|
||||
/// </summary>
|
||||
public sealed record SourceMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiled file path in binary.
|
||||
/// </summary>
|
||||
public required string CompiledPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original source file path.
|
||||
/// </summary>
|
||||
public required string SourcePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source content hash for verification.
|
||||
/// </summary>
|
||||
public string? ContentHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary format types.
|
||||
/// </summary>
|
||||
public enum BinaryFormat
|
||||
{
|
||||
Unknown = 0,
|
||||
Elf = 1,
|
||||
Pe = 2,
|
||||
MachO = 3,
|
||||
Wasm = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol types.
|
||||
/// </summary>
|
||||
public enum SymbolType
|
||||
{
|
||||
Unknown = 0,
|
||||
Function = 1,
|
||||
Variable = 2,
|
||||
Object = 3,
|
||||
Section = 4,
|
||||
File = 5,
|
||||
TlsData = 6
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol binding types.
|
||||
/// </summary>
|
||||
public enum SymbolBinding
|
||||
{
|
||||
Local = 0,
|
||||
Global = 1,
|
||||
Weak = 2
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,158 @@
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Resolution;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of symbol resolver using the symbol repository.
|
||||
/// </summary>
|
||||
public sealed class DefaultSymbolResolver : ISymbolResolver
|
||||
{
|
||||
private readonly ISymbolRepository _repository;
|
||||
|
||||
public DefaultSymbolResolver(ISymbolRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolResolution?> ResolveAsync(
|
||||
string debugId,
|
||||
ulong address,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
var symbol = FindSymbolAtAddress(manifest.Symbols, address);
|
||||
if (symbol is not null)
|
||||
{
|
||||
return new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = true,
|
||||
Symbol = symbol,
|
||||
Offset = address - symbol.Address,
|
||||
DebugId = debugId,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Confidence = 1.0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = false,
|
||||
DebugId = debugId,
|
||||
Confidence = 0.0
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolResolution>> ResolveBatchAsync(
|
||||
string debugId,
|
||||
IEnumerable<ulong> addresses,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = new List<SymbolResolution>();
|
||||
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
SymbolResolution? resolution = null;
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
var symbol = FindSymbolAtAddress(manifest.Symbols, address);
|
||||
if (symbol is not null)
|
||||
{
|
||||
resolution = new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = true,
|
||||
Symbol = symbol,
|
||||
Offset = address - symbol.Address,
|
||||
DebugId = debugId,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Confidence = 1.0
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(resolution ?? new SymbolResolution
|
||||
{
|
||||
Address = address,
|
||||
Found = false,
|
||||
DebugId = debugId,
|
||||
Confidence = 0.0
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SymbolEntry>> GetAllSymbolsAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
SymbolType? typeFilter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = await _repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var symbols = manifests
|
||||
.SelectMany(m => m.Symbols)
|
||||
.Where(s => !typeFilter.HasValue || s.Type == typeFilter.Value)
|
||||
.DistinctBy(s => s.Address)
|
||||
.OrderBy(s => s.Address)
|
||||
.ToList();
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private static SymbolEntry? FindSymbolAtAddress(IReadOnlyList<SymbolEntry> symbols, ulong address)
|
||||
{
|
||||
// Binary search for the symbol containing the address
|
||||
var left = 0;
|
||||
var right = symbols.Count - 1;
|
||||
SymbolEntry? candidate = null;
|
||||
|
||||
while (left <= right)
|
||||
{
|
||||
var mid = left + (right - left) / 2;
|
||||
var symbol = symbols[mid];
|
||||
|
||||
if (address >= symbol.Address && address < symbol.Address + symbol.Size)
|
||||
{
|
||||
return symbol; // Exact match within symbol bounds
|
||||
}
|
||||
|
||||
if (address >= symbol.Address)
|
||||
{
|
||||
candidate = symbol;
|
||||
left = mid + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a candidate and address is within reasonable range, return it
|
||||
if (candidate is not null && address >= candidate.Address && address < candidate.Address + Math.Max(candidate.Size, 4096))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Infrastructure.Resolution;
|
||||
using StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for Symbols infrastructure.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds in-memory symbol services for development and testing.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolsInMemory(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISymbolRepository, InMemorySymbolRepository>();
|
||||
services.TryAddSingleton<ISymbolBlobStore, InMemorySymbolBlobStore>();
|
||||
services.TryAddSingleton<ISymbolResolver, DefaultSymbolResolver>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the default symbol resolver.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSymbolResolver(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<ISymbolResolver, DefaultSymbolResolver>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of symbol blob store for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySymbolBlobStore : ISymbolBlobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BlobEntry> _blobs = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SymbolBlobUploadResult> UploadAsync(
|
||||
Stream content,
|
||||
string tenantId,
|
||||
string debugId,
|
||||
string? fileName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||||
var data = ms.ToArray();
|
||||
|
||||
// Compute hash (using SHA256 as placeholder for BLAKE3)
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
var blobUri = $"cas://symbols/{tenantId}/{debugId}/{hash}";
|
||||
|
||||
var isDuplicate = _blobs.ContainsKey(blobUri);
|
||||
|
||||
var entry = new BlobEntry(
|
||||
Data: data,
|
||||
ContentHash: hash,
|
||||
TenantId: tenantId,
|
||||
DebugId: debugId,
|
||||
FileName: fileName,
|
||||
ContentType: "application/octet-stream",
|
||||
CreatedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
_blobs[blobUri] = entry;
|
||||
|
||||
return new SymbolBlobUploadResult
|
||||
{
|
||||
BlobUri = blobUri,
|
||||
ContentHash = hash,
|
||||
Size = data.Length,
|
||||
IsDuplicate = isDuplicate
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Stream?> DownloadAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(blobUri, out var entry))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(entry.Data));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> ExistsAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.ContainsKey(blobUri));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolBlobMetadata?> GetMetadataAsync(string blobUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_blobs.TryGetValue(blobUri, out var entry))
|
||||
{
|
||||
return Task.FromResult<SymbolBlobMetadata?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<SymbolBlobMetadata?>(new SymbolBlobMetadata
|
||||
{
|
||||
BlobUri = blobUri,
|
||||
ContentHash = entry.ContentHash,
|
||||
Size = entry.Data.Length,
|
||||
ContentType = entry.ContentType,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
TenantId = entry.TenantId,
|
||||
DebugId = entry.DebugId
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> DeleteAsync(string blobUri, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.TryRemove(blobUri, out _));
|
||||
}
|
||||
|
||||
private sealed record BlobEntry(
|
||||
byte[] Data,
|
||||
string ContentHash,
|
||||
string TenantId,
|
||||
string DebugId,
|
||||
string? FileName,
|
||||
string ContentType,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Infrastructure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of symbol repository for development and testing.
|
||||
/// </summary>
|
||||
public sealed class InMemorySymbolRepository : ISymbolRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SymbolManifest> _manifests = new();
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _debugIdIndex = new();
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _codeIdIndex = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string> StoreManifestAsync(SymbolManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_manifests[manifest.ManifestId] = manifest;
|
||||
|
||||
// Update debug ID index
|
||||
_debugIdIndex.AddOrUpdate(
|
||||
manifest.DebugId,
|
||||
_ => [manifest.ManifestId],
|
||||
(_, set) => { set.Add(manifest.ManifestId); return set; });
|
||||
|
||||
// Update code ID index if present
|
||||
if (!string.IsNullOrEmpty(manifest.CodeId))
|
||||
{
|
||||
_codeIdIndex.AddOrUpdate(
|
||||
manifest.CodeId,
|
||||
_ => [manifest.ManifestId],
|
||||
(_, set) => { set.Add(manifest.ManifestId); return set; });
|
||||
}
|
||||
|
||||
return Task.FromResult(manifest.ManifestId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolManifest?> GetManifestAsync(string manifestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_manifests.TryGetValue(manifestId, out var manifest);
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SymbolManifest>> GetManifestsByDebugIdAsync(
|
||||
string debugId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_debugIdIndex.TryGetValue(debugId, out var ids))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>([]);
|
||||
}
|
||||
|
||||
var manifests = ids
|
||||
.Select(id => _manifests.GetValueOrDefault(id))
|
||||
.Where(m => m is not null && (tenantId is null || m.TenantId == tenantId))
|
||||
.Cast<SymbolManifest>()
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SymbolManifest>> GetManifestsByCodeIdAsync(
|
||||
string codeId,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_codeIdIndex.TryGetValue(codeId, out var ids))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>([]);
|
||||
}
|
||||
|
||||
var manifests = ids
|
||||
.Select(id => _manifests.GetValueOrDefault(id))
|
||||
.Where(m => m is not null && (tenantId is null || m.TenantId == tenantId))
|
||||
.Cast<SymbolManifest>()
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SymbolManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SymbolQueryResult> QueryManifestsAsync(SymbolQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifests = _manifests.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
manifests = manifests.Where(m => m.TenantId == query.TenantId);
|
||||
if (!string.IsNullOrEmpty(query.DebugId))
|
||||
manifests = manifests.Where(m => m.DebugId == query.DebugId);
|
||||
if (!string.IsNullOrEmpty(query.CodeId))
|
||||
manifests = manifests.Where(m => m.CodeId == query.CodeId);
|
||||
if (!string.IsNullOrEmpty(query.BinaryName))
|
||||
manifests = manifests.Where(m => m.BinaryName.Contains(query.BinaryName, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(query.Platform))
|
||||
manifests = manifests.Where(m => m.Platform == query.Platform);
|
||||
if (query.Format.HasValue)
|
||||
manifests = manifests.Where(m => m.Format == query.Format.Value);
|
||||
if (query.CreatedAfter.HasValue)
|
||||
manifests = manifests.Where(m => m.CreatedAt >= query.CreatedAfter.Value);
|
||||
if (query.CreatedBefore.HasValue)
|
||||
manifests = manifests.Where(m => m.CreatedAt <= query.CreatedBefore.Value);
|
||||
if (query.HasDsse.HasValue)
|
||||
manifests = manifests.Where(m => !string.IsNullOrEmpty(m.DsseDigest) == query.HasDsse.Value);
|
||||
|
||||
var total = manifests.Count();
|
||||
|
||||
manifests = query.SortDescending
|
||||
? manifests.OrderByDescending(m => m.CreatedAt)
|
||||
: manifests.OrderBy(m => m.CreatedAt);
|
||||
|
||||
var result = manifests
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new SymbolQueryResult
|
||||
{
|
||||
Manifests = result,
|
||||
TotalCount = total,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> ExistsAsync(string manifestId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_manifests.ContainsKey(manifestId));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> DeleteManifestAsync(string manifestId, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_manifests.TryRemove(manifestId, out var manifest))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Remove from indexes
|
||||
if (_debugIdIndex.TryGetValue(manifest.DebugId, out var debugSet))
|
||||
{
|
||||
debugSet.Remove(manifestId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(manifest.CodeId) && _codeIdIndex.TryGetValue(manifest.CodeId, out var codeSet))
|
||||
{
|
||||
codeSet.Remove(manifestId);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
109
src/Symbols/StellaOps.Symbols.Ingestor.Cli/ManifestWriter.cs
Normal file
109
src/Symbols/StellaOps.Symbols.Ingestor.Cli/ManifestWriter.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Ingestor.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Writes symbol manifests to various formats.
|
||||
/// </summary>
|
||||
public static class ManifestWriter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Writes manifest to JSON file.
|
||||
/// </summary>
|
||||
public static async Task<string> WriteJsonAsync(
|
||||
SymbolManifest manifest,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var fileName = $"{manifest.DebugId}.symbols.json";
|
||||
var filePath = Path.Combine(outputDir, fileName);
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
await File.WriteAllTextAsync(filePath, json, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes DSSE envelope to file.
|
||||
/// </summary>
|
||||
public static async Task<string> WriteDsseAsync(
|
||||
string payload,
|
||||
string payloadType,
|
||||
string signature,
|
||||
string keyId,
|
||||
string outputDir,
|
||||
string debugId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var envelope = new DsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)),
|
||||
Signatures =
|
||||
[
|
||||
new DsseSignature { KeyId = keyId, Sig = signature }
|
||||
]
|
||||
};
|
||||
|
||||
var fileName = $"{debugId}.symbols.dsse.json";
|
||||
var filePath = Path.Combine(outputDir, fileName);
|
||||
|
||||
var json = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
await File.WriteAllTextAsync(filePath, json, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads manifest from JSON file.
|
||||
/// </summary>
|
||||
public static async Task<SymbolManifest?> ReadJsonAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<SymbolManifest>(json, JsonOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope structure.
|
||||
/// </summary>
|
||||
public sealed class DsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public List<DsseSignature> Signatures { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature.
|
||||
/// </summary>
|
||||
public sealed class DsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Sig { get; set; } = string.Empty;
|
||||
}
|
||||
416
src/Symbols/StellaOps.Symbols.Ingestor.Cli/Program.cs
Normal file
416
src/Symbols/StellaOps.Symbols.Ingestor.Cli/Program.cs
Normal file
@@ -0,0 +1,416 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Symbols.Client;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using StellaOps.Symbols.Ingestor.Cli;
|
||||
|
||||
return await RunAsync(args).ConfigureAwait(false);
|
||||
|
||||
static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
// Build command structure
|
||||
var rootCommand = new RootCommand("StellaOps Symbol Ingestor CLI - Ingest and publish symbol manifests");
|
||||
|
||||
// Global options
|
||||
var verboseOption = new Option<bool>("--verbose")
|
||||
{
|
||||
Description = "Enable verbose output"
|
||||
};
|
||||
var dryRunOption = new Option<bool>("--dry-run")
|
||||
{
|
||||
Description = "Dry run mode - generate manifest without uploading"
|
||||
};
|
||||
|
||||
rootCommand.Add(verboseOption);
|
||||
rootCommand.Add(dryRunOption);
|
||||
|
||||
// ingest command
|
||||
var ingestCommand = new Command("ingest", "Ingest symbols from a binary file");
|
||||
|
||||
var binaryOption = new Option<string>("--binary")
|
||||
{
|
||||
Description = "Path to the binary file",
|
||||
Required = true
|
||||
};
|
||||
var debugOption = new Option<string?>("--debug")
|
||||
{
|
||||
Description = "Path to debug symbols file (PDB, DWARF, dSYM)"
|
||||
};
|
||||
var debugIdOption = new Option<string?>("--debug-id")
|
||||
{
|
||||
Description = "Override debug ID"
|
||||
};
|
||||
var codeIdOption = new Option<string?>("--code-id")
|
||||
{
|
||||
Description = "Override code ID"
|
||||
};
|
||||
var nameOption = new Option<string?>("--name")
|
||||
{
|
||||
Description = "Override binary name"
|
||||
};
|
||||
var platformOption = new Option<string?>("--platform")
|
||||
{
|
||||
Description = "Platform identifier (linux-x64, win-x64, osx-arm64, etc.)"
|
||||
};
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Output directory for manifest files (default: current directory)"
|
||||
};
|
||||
var serverOption = new Option<string?>("--server")
|
||||
{
|
||||
Description = "Symbols server URL for upload"
|
||||
};
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant ID for multi-tenant uploads"
|
||||
};
|
||||
|
||||
ingestCommand.Add(binaryOption);
|
||||
ingestCommand.Add(debugOption);
|
||||
ingestCommand.Add(debugIdOption);
|
||||
ingestCommand.Add(codeIdOption);
|
||||
ingestCommand.Add(nameOption);
|
||||
ingestCommand.Add(platformOption);
|
||||
ingestCommand.Add(outputOption);
|
||||
ingestCommand.Add(serverOption);
|
||||
ingestCommand.Add(tenantOption);
|
||||
|
||||
ingestCommand.SetAction(async (parseResult, cancellationToken) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var binary = parseResult.GetValue(binaryOption)!;
|
||||
var debug = parseResult.GetValue(debugOption);
|
||||
var debugId = parseResult.GetValue(debugIdOption);
|
||||
var codeId = parseResult.GetValue(codeIdOption);
|
||||
var name = parseResult.GetValue(nameOption);
|
||||
var platform = parseResult.GetValue(platformOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? ".";
|
||||
var server = parseResult.GetValue(serverOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
var options = new SymbolIngestOptions
|
||||
{
|
||||
BinaryPath = binary,
|
||||
DebugPath = debug,
|
||||
DebugId = debugId,
|
||||
CodeId = codeId,
|
||||
BinaryName = name,
|
||||
Platform = platform,
|
||||
OutputDir = output,
|
||||
ServerUrl = server,
|
||||
TenantId = tenant,
|
||||
Verbose = verbose,
|
||||
DryRun = dryRun
|
||||
};
|
||||
|
||||
await IngestAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
// upload command
|
||||
var uploadCommand = new Command("upload", "Upload a symbol manifest to the server");
|
||||
|
||||
var manifestOption = new Option<string>("--manifest")
|
||||
{
|
||||
Description = "Path to manifest JSON file",
|
||||
Required = true
|
||||
};
|
||||
var uploadServerOption = new Option<string>("--server")
|
||||
{
|
||||
Description = "Symbols server URL",
|
||||
Required = true
|
||||
};
|
||||
var uploadTenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant ID for multi-tenant uploads"
|
||||
};
|
||||
|
||||
uploadCommand.Add(manifestOption);
|
||||
uploadCommand.Add(uploadServerOption);
|
||||
uploadCommand.Add(uploadTenantOption);
|
||||
|
||||
uploadCommand.SetAction(async (parseResult, cancellationToken) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var manifestPath = parseResult.GetValue(manifestOption)!;
|
||||
var server = parseResult.GetValue(uploadServerOption)!;
|
||||
var tenant = parseResult.GetValue(uploadTenantOption);
|
||||
|
||||
await UploadAsync(manifestPath, server, tenant, verbose, dryRun, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
// verify command
|
||||
var verifyCommand = new Command("verify", "Verify a symbol manifest or DSSE envelope");
|
||||
|
||||
var verifyPathOption = new Option<string>("--path")
|
||||
{
|
||||
Description = "Path to manifest or DSSE file",
|
||||
Required = true
|
||||
};
|
||||
|
||||
verifyCommand.Add(verifyPathOption);
|
||||
|
||||
verifyCommand.SetAction(async (parseResult, cancellationToken) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var path = parseResult.GetValue(verifyPathOption)!;
|
||||
|
||||
await VerifyAsync(path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
// health command
|
||||
var healthCommand = new Command("health", "Check symbols server health");
|
||||
|
||||
var healthServerOption = new Option<string>("--server")
|
||||
{
|
||||
Description = "Symbols server URL",
|
||||
Required = true
|
||||
};
|
||||
|
||||
healthCommand.Add(healthServerOption);
|
||||
|
||||
healthCommand.SetAction(async (parseResult, cancellationToken) =>
|
||||
{
|
||||
var server = parseResult.GetValue(healthServerOption)!;
|
||||
await HealthCheckAsync(server, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
rootCommand.Add(ingestCommand);
|
||||
rootCommand.Add(uploadCommand);
|
||||
rootCommand.Add(verifyCommand);
|
||||
rootCommand.Add(healthCommand);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
var parseResult = rootCommand.Parse(args);
|
||||
return await parseResult.InvokeAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Command implementations
|
||||
static async Task IngestAsync(SymbolIngestOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold blue]StellaOps Symbol Ingestor[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Validate binary exists
|
||||
if (!File.Exists(options.BinaryPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Binary file not found: {options.BinaryPath}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect format
|
||||
var format = SymbolExtractor.DetectFormat(options.BinaryPath);
|
||||
AnsiConsole.MarkupLine($"[green]Binary format:[/] {format}");
|
||||
|
||||
if (format == BinaryFormat.Unknown)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Unknown binary format");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create manifest
|
||||
SymbolManifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = SymbolExtractor.CreateManifest(options.BinaryPath, options.DebugPath, options);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error creating manifest:[/] {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
|
||||
if (!string.IsNullOrEmpty(manifest.CodeId))
|
||||
AnsiConsole.MarkupLine($"[green]Code ID:[/] {manifest.CodeId}");
|
||||
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
|
||||
AnsiConsole.MarkupLine($"[green]Platform:[/] {manifest.Platform}");
|
||||
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}");
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = await ManifestWriter.WriteJsonAsync(manifest, options.OutputDir, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine($"[green]Manifest written:[/] {manifestPath}");
|
||||
|
||||
// Upload if server specified and not dry-run
|
||||
if (!string.IsNullOrEmpty(options.ServerUrl) && !options.DryRun)
|
||||
{
|
||||
await UploadAsync(manifestPath, options.ServerUrl, options.TenantId, options.Verbose, false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (options.DryRun)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Dry run mode - skipping upload[/]");
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold green]Done![/]");
|
||||
}
|
||||
|
||||
static async Task UploadAsync(
|
||||
string manifestPath,
|
||||
string serverUrl,
|
||||
string? tenantId,
|
||||
bool verbose,
|
||||
bool dryRun,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (dryRun)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Dry run mode - would upload to:[/] {0}", serverUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
var manifest = await ManifestWriter.ReadJsonAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to read manifest: {manifestPath}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up HTTP client and symbols client
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
if (verbose)
|
||||
builder.AddConsole().SetMinimumLevel(LogLevel.Debug);
|
||||
});
|
||||
services.AddSymbolsClient(opts =>
|
||||
{
|
||||
opts.BaseUrl = serverUrl;
|
||||
opts.TenantId = tenantId;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var client = provider.GetRequiredService<ISymbolsClient>();
|
||||
|
||||
AnsiConsole.MarkupLine($"[blue]Uploading to:[/] {serverUrl}");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await client.UploadManifestAsync(manifest, cancellationToken).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine($"[green]Uploaded:[/] {result.ManifestId}");
|
||||
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {result.SymbolCount}");
|
||||
if (!string.IsNullOrEmpty(result.BlobUri))
|
||||
AnsiConsole.MarkupLine($"[green]Blob URI:[/] {result.BlobUri}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Upload failed:[/] {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
static Task VerifyAsync(string path, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {path}");
|
||||
Environment.ExitCode = 1;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
|
||||
// Check if it's a DSSE envelope or a plain manifest
|
||||
if (json.Contains("\"payloadType\"") && json.Contains("\"signatures\""))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Verifying DSSE envelope...[/]");
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json);
|
||||
if (envelope is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Invalid DSSE envelope");
|
||||
Environment.ExitCode = 1;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Payload type:[/] {envelope.PayloadType}");
|
||||
AnsiConsole.MarkupLine($"[green]Signatures:[/] {envelope.Signatures.Count}");
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [dim]Key ID:[/] {sig.KeyId}");
|
||||
AnsiConsole.MarkupLine($" [dim]Signature:[/] {sig.Sig[..Math.Min(32, sig.Sig.Length)]}...");
|
||||
}
|
||||
|
||||
// Decode and parse payload
|
||||
try
|
||||
{
|
||||
var payloadJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload));
|
||||
var manifest = JsonSerializer.Deserialize<SymbolManifest>(payloadJson);
|
||||
if (manifest is not null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
|
||||
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Warning:[/] Could not decode payload");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Verifying manifest...[/]");
|
||||
var manifest = JsonSerializer.Deserialize<SymbolManifest>(json);
|
||||
if (manifest is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Invalid manifest");
|
||||
Environment.ExitCode = 1;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Manifest ID:[/] {manifest.ManifestId}");
|
||||
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
|
||||
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
|
||||
AnsiConsole.MarkupLine($"[green]Format:[/] {manifest.Format}");
|
||||
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}");
|
||||
AnsiConsole.MarkupLine($"[green]Created:[/] {manifest.CreatedAt:O}");
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[bold green]Verification passed![/]");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static async Task HealthCheckAsync(string serverUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSymbolsClient(opts => opts.BaseUrl = serverUrl);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var client = provider.GetRequiredService<ISymbolsClient>();
|
||||
|
||||
AnsiConsole.MarkupLine($"[blue]Checking health:[/] {serverUrl}");
|
||||
|
||||
try
|
||||
{
|
||||
var health = await client.GetHealthAsync(cancellationToken).ConfigureAwait(false);
|
||||
AnsiConsole.MarkupLine($"[green]Status:[/] {health.Status}");
|
||||
AnsiConsole.MarkupLine($"[green]Version:[/] {health.Version}");
|
||||
AnsiConsole.MarkupLine($"[green]Timestamp:[/] {health.Timestamp:O}");
|
||||
if (health.TotalManifests.HasValue)
|
||||
AnsiConsole.MarkupLine($"[green]Total manifests:[/] {health.TotalManifests}");
|
||||
if (health.TotalSymbols.HasValue)
|
||||
AnsiConsole.MarkupLine($"[green]Total symbols:[/] {health.TotalSymbols}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Health check failed:[/] {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<AssemblyName>stella-symbols</AssemblyName>
|
||||
<RootNamespace>StellaOps.Symbols.Ingestor.Cli</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.48.0" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Client\StellaOps.Symbols.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
170
src/Symbols/StellaOps.Symbols.Ingestor.Cli/SymbolExtractor.cs
Normal file
170
src/Symbols/StellaOps.Symbols.Ingestor.Cli/SymbolExtractor.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Ingestor.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts symbol information from binary files.
|
||||
/// </summary>
|
||||
public static class SymbolExtractor
|
||||
{
|
||||
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46]; // \x7FELF
|
||||
private static readonly byte[] PeMagic = [0x4D, 0x5A]; // MZ
|
||||
private static readonly byte[] MachO32Magic = [0xFE, 0xED, 0xFA, 0xCE]; // 0xFEEDFACE
|
||||
private static readonly byte[] MachO64Magic = [0xFE, 0xED, 0xFA, 0xCF]; // 0xFEEDFACF
|
||||
private static readonly byte[] MachOFatMagic = [0xCA, 0xFE, 0xBA, 0xBE]; // 0xCAFEBABE
|
||||
private static readonly byte[] WasmMagic = [0x00, 0x61, 0x73, 0x6D]; // \0asm
|
||||
|
||||
/// <summary>
|
||||
/// Detects the binary format from file header.
|
||||
/// </summary>
|
||||
public static BinaryFormat DetectFormat(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var header = new byte[4];
|
||||
if (stream.Read(header, 0, 4) < 4)
|
||||
{
|
||||
return BinaryFormat.Unknown;
|
||||
}
|
||||
|
||||
if (header.AsSpan().StartsWith(ElfMagic))
|
||||
return BinaryFormat.Elf;
|
||||
if (header.AsSpan(0, 2).SequenceEqual(PeMagic))
|
||||
return BinaryFormat.Pe;
|
||||
if (header.AsSpan().SequenceEqual(MachO32Magic) ||
|
||||
header.AsSpan().SequenceEqual(MachO64Magic) ||
|
||||
header.AsSpan().SequenceEqual(MachOFatMagic))
|
||||
return BinaryFormat.MachO;
|
||||
if (header.AsSpan().SequenceEqual(WasmMagic))
|
||||
return BinaryFormat.Wasm;
|
||||
|
||||
return BinaryFormat.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts debug ID from binary.
|
||||
/// For ELF: .note.gnu.build-id
|
||||
/// For PE: PDB GUID from debug directory
|
||||
/// For Mach-O: LC_UUID
|
||||
/// </summary>
|
||||
public static string? ExtractDebugId(string filePath, BinaryFormat format)
|
||||
{
|
||||
// Note: Full implementation would parse each format's debug ID section.
|
||||
// This is a placeholder that computes a hash-based ID.
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var hash = SHA256.HashData(stream);
|
||||
|
||||
return format switch
|
||||
{
|
||||
BinaryFormat.Elf => Convert.ToHexString(hash.AsSpan(0, 20)).ToLowerInvariant(),
|
||||
BinaryFormat.Pe => FormatPdbGuid(hash.AsSpan(0, 16)),
|
||||
BinaryFormat.MachO => FormatUuid(hash.AsSpan(0, 16)),
|
||||
BinaryFormat.Wasm => Convert.ToHexString(hash.AsSpan(0, 20)).ToLowerInvariant(),
|
||||
_ => Convert.ToHexString(hash.AsSpan(0, 20)).ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts code ID (optional, format-specific).
|
||||
/// </summary>
|
||||
public static string? ExtractCodeId(string filePath, BinaryFormat format)
|
||||
{
|
||||
// Code ID is typically derived from:
|
||||
// - PE: TimeDateStamp + SizeOfImage
|
||||
// - ELF: Same as build-id for most cases
|
||||
// - Mach-O: Same as UUID
|
||||
return null; // Placeholder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes content hash for a file using BLAKE3 (or SHA256 fallback).
|
||||
/// </summary>
|
||||
public static string ComputeContentHash(string filePath)
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
// Using SHA256 as placeholder until BLAKE3 is integrated
|
||||
var hash = SHA256.HashData(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a symbol manifest from binary analysis.
|
||||
/// </summary>
|
||||
public static SymbolManifest CreateManifest(
|
||||
string binaryPath,
|
||||
string? debugPath,
|
||||
SymbolIngestOptions options)
|
||||
{
|
||||
var format = DetectFormat(binaryPath);
|
||||
if (format == BinaryFormat.Unknown)
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown binary format: {binaryPath}");
|
||||
}
|
||||
|
||||
var debugId = options.DebugId ?? ExtractDebugId(binaryPath, format)
|
||||
?? throw new InvalidOperationException($"Could not extract debug ID from: {binaryPath}");
|
||||
|
||||
var codeId = options.CodeId ?? ExtractCodeId(binaryPath, format);
|
||||
var binaryName = options.BinaryName ?? Path.GetFileName(binaryPath);
|
||||
var platform = options.Platform ?? DetectPlatform(format);
|
||||
|
||||
// Note: Full implementation would parse symbol tables from binary/debug files
|
||||
// For now, create manifest with metadata only
|
||||
var symbols = new List<SymbolEntry>();
|
||||
|
||||
// If debug file exists, record its hash
|
||||
string? debugContentHash = null;
|
||||
if (!string.IsNullOrEmpty(debugPath) && File.Exists(debugPath))
|
||||
{
|
||||
debugContentHash = ComputeContentHash(debugPath);
|
||||
}
|
||||
|
||||
return new SymbolManifest
|
||||
{
|
||||
ManifestId = Guid.NewGuid().ToString("N"),
|
||||
DebugId = debugId,
|
||||
CodeId = codeId,
|
||||
BinaryName = binaryName,
|
||||
Platform = platform,
|
||||
Format = format,
|
||||
TenantId = options.TenantId ?? "default",
|
||||
Symbols = symbols,
|
||||
SourceMappings = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatPdbGuid(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
// Format as GUID + age (simplified)
|
||||
var guid = new Guid(bytes.ToArray());
|
||||
return guid.ToString("N").ToUpperInvariant() + "1";
|
||||
}
|
||||
|
||||
private static string FormatUuid(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
// Format as UUID (hyphenated)
|
||||
var guid = new Guid(bytes.ToArray());
|
||||
return guid.ToString("D").ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string DetectPlatform(BinaryFormat format)
|
||||
{
|
||||
// Default platform detection based on format and runtime
|
||||
return format switch
|
||||
{
|
||||
BinaryFormat.Pe => "win-x64",
|
||||
BinaryFormat.MachO => OperatingSystem.IsMacOS() ? "osx-arm64" : "osx-x64",
|
||||
BinaryFormat.Elf => "linux-x64",
|
||||
BinaryFormat.Wasm => "wasm32",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace StellaOps.Symbols.Ingestor.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Options for symbol ingestion.
|
||||
/// </summary>
|
||||
public sealed class SymbolIngestOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the binary file (ELF, PE, Mach-O, WASM).
|
||||
/// </summary>
|
||||
public string BinaryPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the debug symbols file (PDB, DWARF, dSYM).
|
||||
/// </summary>
|
||||
public string? DebugPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override debug ID (otherwise extracted from binary).
|
||||
/// </summary>
|
||||
public string? DebugId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override code ID (otherwise extracted from binary).
|
||||
/// </summary>
|
||||
public string? CodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override binary name (otherwise derived from file name).
|
||||
/// </summary>
|
||||
public string? BinaryName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform identifier (linux-x64, win-x64, osx-arm64, etc.).
|
||||
/// </summary>
|
||||
public string? Platform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for manifest files.
|
||||
/// </summary>
|
||||
public string OutputDir { get; set; } = ".";
|
||||
|
||||
/// <summary>
|
||||
/// Symbols server URL for upload.
|
||||
/// </summary>
|
||||
public string? ServerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant uploads.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sign the manifest with DSSE.
|
||||
/// </summary>
|
||||
public bool Sign { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to signing key (for DSSE signing).
|
||||
/// </summary>
|
||||
public string? SigningKeyPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Submit to Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool SubmitRekor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor server URL.
|
||||
/// </summary>
|
||||
public string RekorUrl { get; set; } = "https://rekor.sigstore.dev";
|
||||
|
||||
/// <summary>
|
||||
/// Emit verbose output.
|
||||
/// </summary>
|
||||
public bool Verbose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dry run mode - generate manifest without uploading.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
|
||||
namespace StellaOps.Symbols.Server.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to upload a symbol manifest.
|
||||
/// </summary>
|
||||
public sealed record UploadSymbolManifestRequest(
|
||||
string DebugId,
|
||||
string BinaryName,
|
||||
string? CodeId,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
IReadOnlyList<SymbolEntryDto> Symbols,
|
||||
IReadOnlyList<SourceMappingDto>? SourceMappings);
|
||||
|
||||
/// <summary>
|
||||
/// Symbol entry DTO for API.
|
||||
/// </summary>
|
||||
public sealed record SymbolEntryDto(
|
||||
ulong Address,
|
||||
ulong Size,
|
||||
string MangledName,
|
||||
string? DemangledName,
|
||||
SymbolType Type,
|
||||
SymbolBinding Binding,
|
||||
string? SourceFile,
|
||||
int? SourceLine,
|
||||
string? ContentHash);
|
||||
|
||||
/// <summary>
|
||||
/// Source mapping DTO for API.
|
||||
/// </summary>
|
||||
public sealed record SourceMappingDto(
|
||||
string CompiledPath,
|
||||
string SourcePath,
|
||||
string? ContentHash);
|
||||
|
||||
/// <summary>
|
||||
/// Response from manifest upload.
|
||||
/// </summary>
|
||||
public sealed record UploadSymbolManifestResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string BinaryName,
|
||||
string? BlobUri,
|
||||
int SymbolCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve symbols.
|
||||
/// </summary>
|
||||
public sealed record ResolveSymbolsRequest(
|
||||
string DebugId,
|
||||
IReadOnlyList<ulong> Addresses);
|
||||
|
||||
/// <summary>
|
||||
/// Response from symbol resolution.
|
||||
/// </summary>
|
||||
public sealed record ResolveSymbolsResponse(
|
||||
string DebugId,
|
||||
IReadOnlyList<SymbolResolutionDto> Resolutions);
|
||||
|
||||
/// <summary>
|
||||
/// Symbol resolution DTO.
|
||||
/// </summary>
|
||||
public sealed record SymbolResolutionDto(
|
||||
ulong Address,
|
||||
bool Found,
|
||||
string? MangledName,
|
||||
string? DemangledName,
|
||||
ulong Offset,
|
||||
string? SourceFile,
|
||||
int? SourceLine,
|
||||
double Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Symbol manifest list response.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestListResponse(
|
||||
IReadOnlyList<SymbolManifestSummary> Manifests,
|
||||
int TotalCount,
|
||||
int Offset,
|
||||
int Limit);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a symbol manifest.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestSummary(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string? CodeId,
|
||||
string BinaryName,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
int SymbolCount,
|
||||
bool HasDsse,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed manifest response.
|
||||
/// </summary>
|
||||
public sealed record SymbolManifestDetailResponse(
|
||||
string ManifestId,
|
||||
string DebugId,
|
||||
string? CodeId,
|
||||
string BinaryName,
|
||||
string? Platform,
|
||||
BinaryFormat Format,
|
||||
string TenantId,
|
||||
string? BlobUri,
|
||||
string? DsseDigest,
|
||||
long? RekorLogIndex,
|
||||
int SymbolCount,
|
||||
IReadOnlyList<SymbolEntryDto> Symbols,
|
||||
IReadOnlyList<SourceMappingDto>? SourceMappings,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Health check response.
|
||||
/// </summary>
|
||||
public sealed record SymbolsHealthResponse(
|
||||
string Status,
|
||||
string Version,
|
||||
DateTimeOffset Timestamp,
|
||||
SymbolsHealthMetrics? Metrics);
|
||||
|
||||
/// <summary>
|
||||
/// Health metrics.
|
||||
/// </summary>
|
||||
public sealed record SymbolsHealthMetrics(
|
||||
long TotalManifests,
|
||||
long TotalSymbols,
|
||||
long TotalBlobBytes);
|
||||
323
src/Symbols/StellaOps.Symbols.Server/Program.cs
Normal file
323
src/Symbols/StellaOps.Symbols.Server/Program.cs
Normal file
@@ -0,0 +1,323 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Symbols.Core.Abstractions;
|
||||
using StellaOps.Symbols.Core.Models;
|
||||
using StellaOps.Symbols.Infrastructure;
|
||||
using StellaOps.Symbols.Server.Contracts;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Authentication and Authorization
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configure: options =>
|
||||
{
|
||||
options.RequiredScopes.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.DefaultPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
options.FallbackPolicy = options.DefaultPolicy;
|
||||
});
|
||||
|
||||
// Symbols services (in-memory for development)
|
||||
builder.Services.AddSymbolsInMemory();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
const string SymbolsReadPolicy = "symbols:read";
|
||||
const string SymbolsWritePolicy = "symbols:write";
|
||||
|
||||
// Health endpoint (anonymous)
|
||||
app.MapGet("/health", () =>
|
||||
{
|
||||
return TypedResults.Ok(new SymbolsHealthResponse(
|
||||
Status: "healthy",
|
||||
Version: "1.0.0",
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
Metrics: null));
|
||||
})
|
||||
.AllowAnonymous()
|
||||
.WithName("GetHealth")
|
||||
.WithSummary("Health check endpoint");
|
||||
|
||||
// Upload symbol manifest
|
||||
app.MapPost("/v1/symbols/manifests", async Task<Results<Created<UploadSymbolManifestResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
UploadSymbolManifestRequest request,
|
||||
ISymbolRepository repository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var symbols = request.Symbols.Select(s => new SymbolEntry
|
||||
{
|
||||
Address = s.Address,
|
||||
Size = s.Size,
|
||||
MangledName = s.MangledName,
|
||||
DemangledName = s.DemangledName,
|
||||
Type = s.Type,
|
||||
Binding = s.Binding,
|
||||
SourceFile = s.SourceFile,
|
||||
SourceLine = s.SourceLine,
|
||||
ContentHash = s.ContentHash
|
||||
}).ToList();
|
||||
|
||||
var sourceMappings = request.SourceMappings?.Select(m => new SourceMapping
|
||||
{
|
||||
CompiledPath = m.CompiledPath,
|
||||
SourcePath = m.SourcePath,
|
||||
ContentHash = m.ContentHash
|
||||
}).ToList();
|
||||
|
||||
var manifestId = ComputeManifestId(request.DebugId, tenantId, symbols);
|
||||
|
||||
var manifest = new SymbolManifest
|
||||
{
|
||||
ManifestId = manifestId,
|
||||
DebugId = request.DebugId,
|
||||
CodeId = request.CodeId,
|
||||
BinaryName = request.BinaryName,
|
||||
Platform = request.Platform,
|
||||
Format = request.Format,
|
||||
Symbols = symbols,
|
||||
SourceMappings = sourceMappings,
|
||||
TenantId = tenantId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await repository.StoreManifestAsync(manifest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new UploadSymbolManifestResponse(
|
||||
ManifestId: manifestId,
|
||||
DebugId: request.DebugId,
|
||||
BinaryName: request.BinaryName,
|
||||
BlobUri: manifest.BlobUri,
|
||||
SymbolCount: symbols.Count,
|
||||
CreatedAt: manifest.CreatedAt);
|
||||
|
||||
return TypedResults.Created($"/v1/symbols/manifests/{manifestId}", response);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithName("UploadSymbolManifest")
|
||||
.WithSummary("Upload a symbol manifest")
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Get manifest by ID
|
||||
app.MapGet("/v1/symbols/manifests/{manifestId}", async Task<Results<Ok<SymbolManifestDetailResponse>, NotFound, ProblemHttpResult>> (
|
||||
string manifestId,
|
||||
ISymbolRepository repository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var manifest = await repository.GetManifestAsync(manifestId, cancellationToken).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = MapToDetailResponse(manifest);
|
||||
return TypedResults.Ok(response);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithName("GetSymbolManifest")
|
||||
.WithSummary("Get symbol manifest by ID")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Query manifests
|
||||
app.MapGet("/v1/symbols/manifests", async Task<Results<Ok<SymbolManifestListResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
ISymbolRepository repository,
|
||||
string? debugId,
|
||||
string? codeId,
|
||||
string? binaryName,
|
||||
string? platform,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var query = new SymbolQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DebugId = debugId,
|
||||
CodeId = codeId,
|
||||
BinaryName = binaryName,
|
||||
Platform = platform,
|
||||
Limit = limit ?? 50,
|
||||
Offset = offset ?? 0
|
||||
};
|
||||
|
||||
var result = await repository.QueryManifestsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var summaries = result.Manifests.Select(m => new SymbolManifestSummary(
|
||||
ManifestId: m.ManifestId,
|
||||
DebugId: m.DebugId,
|
||||
CodeId: m.CodeId,
|
||||
BinaryName: m.BinaryName,
|
||||
Platform: m.Platform,
|
||||
Format: m.Format,
|
||||
SymbolCount: m.Symbols.Count,
|
||||
HasDsse: !string.IsNullOrEmpty(m.DsseDigest),
|
||||
CreatedAt: m.CreatedAt)).ToList();
|
||||
|
||||
return TypedResults.Ok(new SymbolManifestListResponse(
|
||||
Manifests: summaries,
|
||||
TotalCount: result.TotalCount,
|
||||
Offset: result.Offset,
|
||||
Limit: result.Limit));
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithName("QuerySymbolManifests")
|
||||
.WithSummary("Query symbol manifests")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Resolve symbols
|
||||
app.MapPost("/v1/symbols/resolve", async Task<Results<Ok<ResolveSymbolsResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
ResolveSymbolsRequest request,
|
||||
ISymbolResolver resolver,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var resolutions = await resolver.ResolveBatchAsync(
|
||||
request.DebugId,
|
||||
request.Addresses,
|
||||
tenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var dtos = resolutions.Select(r => new SymbolResolutionDto(
|
||||
Address: r.Address,
|
||||
Found: r.Found,
|
||||
MangledName: r.Symbol?.MangledName,
|
||||
DemangledName: r.Symbol?.DemangledName,
|
||||
Offset: r.Offset,
|
||||
SourceFile: r.Symbol?.SourceFile,
|
||||
SourceLine: r.Symbol?.SourceLine,
|
||||
Confidence: r.Confidence)).ToList();
|
||||
|
||||
return TypedResults.Ok(new ResolveSymbolsResponse(
|
||||
DebugId: request.DebugId,
|
||||
Resolutions: dtos));
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithName("ResolveSymbols")
|
||||
.WithSummary("Resolve symbol addresses")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
// Get manifests by debug ID
|
||||
app.MapGet("/v1/symbols/by-debug-id/{debugId}", async Task<Results<Ok<SymbolManifestListResponse>, ProblemHttpResult>> (
|
||||
HttpContext httpContext,
|
||||
string debugId,
|
||||
ISymbolRepository repository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
||||
{
|
||||
return tenantProblem!;
|
||||
}
|
||||
|
||||
var manifests = await repository.GetManifestsByDebugIdAsync(debugId, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var summaries = manifests.Select(m => new SymbolManifestSummary(
|
||||
ManifestId: m.ManifestId,
|
||||
DebugId: m.DebugId,
|
||||
CodeId: m.CodeId,
|
||||
BinaryName: m.BinaryName,
|
||||
Platform: m.Platform,
|
||||
Format: m.Format,
|
||||
SymbolCount: m.Symbols.Count,
|
||||
HasDsse: !string.IsNullOrEmpty(m.DsseDigest),
|
||||
CreatedAt: m.CreatedAt)).ToList();
|
||||
|
||||
return TypedResults.Ok(new SymbolManifestListResponse(
|
||||
Manifests: summaries,
|
||||
TotalCount: summaries.Count,
|
||||
Offset: 0,
|
||||
Limit: summaries.Count));
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithName("GetManifestsByDebugId")
|
||||
.WithSummary("Get manifests by debug ID")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest);
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool TryGetTenant(HttpContext httpContext, out ProblemHttpResult? problem, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) ||
|
||||
string.IsNullOrWhiteSpace(tenantValues))
|
||||
{
|
||||
problem = TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = tenantValues.ToString();
|
||||
problem = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
static string ComputeManifestId(string debugId, string tenantId, IReadOnlyList<SymbolEntry> symbols)
|
||||
{
|
||||
// Simplified hash computation (should use BLAKE3 in production)
|
||||
var combined = $"{debugId}:{tenantId}:{symbols.Count}:{DateTimeOffset.UtcNow.Ticks}";
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
|
||||
}
|
||||
|
||||
static SymbolManifestDetailResponse MapToDetailResponse(SymbolManifest manifest)
|
||||
{
|
||||
return new SymbolManifestDetailResponse(
|
||||
ManifestId: manifest.ManifestId,
|
||||
DebugId: manifest.DebugId,
|
||||
CodeId: manifest.CodeId,
|
||||
BinaryName: manifest.BinaryName,
|
||||
Platform: manifest.Platform,
|
||||
Format: manifest.Format,
|
||||
TenantId: manifest.TenantId,
|
||||
BlobUri: manifest.BlobUri,
|
||||
DsseDigest: manifest.DsseDigest,
|
||||
RekorLogIndex: manifest.RekorLogIndex,
|
||||
SymbolCount: manifest.Symbols.Count,
|
||||
Symbols: manifest.Symbols.Select(s => new SymbolEntryDto(
|
||||
s.Address, s.Size, s.MangledName, s.DemangledName,
|
||||
s.Type, s.Binding, s.SourceFile, s.SourceLine, s.ContentHash)).ToList(),
|
||||
SourceMappings: manifest.SourceMappings?.Select(m => new SourceMappingDto(
|
||||
m.CompiledPath, m.SourcePath, m.ContentHash)).ToList(),
|
||||
CreatedAt: manifest.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Symbols.Infrastructure\StellaOps.Symbols.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user