22 KiB
StellaOps Router Migration Guide
This guide describes how to migrate existing StellaOps.*.WebService projects to the new microservice pattern with the StellaOps Router.
Overview
The router provides a transport-agnostic communication layer between services, replacing direct HTTP calls with efficient binary protocols (TCP, TLS, UDP, RabbitMQ). Benefits include:
- Performance: Binary framing vs HTTP overhead
- Streaming: First-class support for large payloads
- Cancellation: Propagated across service boundaries
- Claims: Authority-integrated authorization
- Health: Automatic heartbeat and failover
Prerequisites
Before migrating, ensure:
- Router infrastructure is deployed (Gateway, transports)
- Authority is configured with endpoint claims
- Local development environment has router.yaml configured
Migration Strategies
Strategy A: In-Place Adaptation
Best for services that need to maintain HTTP compatibility during transition.
┌─────────────────────────────────────┐
│ StellaOps.*.WebService │
│ ┌─────────────────────────────────┐│
│ │ Existing HTTP Controllers ││◄── HTTP clients (legacy)
│ └─────────────────────────────────┘│
│ ┌─────────────────────────────────┐│
│ │ [StellaEndpoint] Handlers ││◄── Router (new)
│ └─────────────────────────────────┘│
│ ┌─────────────────────────────────┐│
│ │ Shared Domain Logic ││
│ └─────────────────────────────────┘│
└─────────────────────────────────────┘
Steps:
- Add
StellaOps.Microservicepackage reference - Create handler classes for each HTTP route
- Handlers call existing service layer
- Register with router alongside HTTP
- Test via router
- Shift traffic gradually
- Remove HTTP controllers when ready
Pros:
- Gradual migration
- No downtime
- Can roll back easily
Cons:
- Dual maintenance during transition
- May delay cleanup
Strategy B: Clean Split
Best for major refactoring or when HTTP compatibility is not needed.
┌─────────────────────────────────────┐
│ StellaOps.*.Domain │ ◄── Shared library
│ (extracted business logic) │
└─────────────────────────────────────┘
▲ ▲
│ │
┌─────────┴───────┐ ┌───────┴─────────┐
│ (Legacy) │ │ (New) │
│ *.WebService │ │ *.Microservice │
│ HTTP only │ │ Router only │
└─────────────────┘ └─────────────────┘
Steps:
- Extract domain logic to
.Domainlibrary - Create new
.Microserviceproject - Implement handlers using domain library
- Deploy alongside WebService
- Shift traffic to router
- Deprecate WebService
Pros:
- Clean architecture
- No legacy code in new project
- Clear separation of concerns
Cons:
- More upfront work
- Requires domain extraction
Strategy C: ASP.NET Endpoint Bridge (Recommended)
Best for services that want automatic Router registration from existing ASP.NET endpoints without code changes.
┌─────────────────────────────────────────────────────────────────┐
│ StellaOps.*.WebService │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ ASP.NET Endpoints (unchanged) ││
│ │ • Minimal APIs: app.MapGet("/api/...", handler) ││
│ │ • Controllers: [ApiController] with [HttpGet], etc. ││
│ │ • Route groups: app.MapGroup("/api").MapEndpoints() ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ Auto-discovery │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ StellaOps.Microservice.AspNetCore ││
│ │ • Discovers all endpoints from EndpointDataSource ││
│ │ • Extracts authorization metadata automatically ││
│ │ • Registers with Router via HELLO ││
│ │ • Dispatches Router requests through ASP.NET pipeline ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ Binary transport │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Router Gateway ││
│ │ • Routes external HTTP → internal microservice ││
│ │ • Aggregates OpenAPI ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
Steps:
- Add
StellaOps.Microservice.AspNetCorepackage reference - Configure bridge in
Program.cs:
using StellaOps.Microservice.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add authorization (required for claim mapping)
builder.Services.AddAuthorization();
// Add the ASP.NET Endpoint Bridge
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "my-service";
options.Version = "1.0.0";
options.Region = "us-east-1";
});
var app = builder.Build();
// Register ASP.NET endpoints as usual
app.MapGet("/api/health", () => "healthy");
app.MapGet("/api/items/{id}", (string id) => new { id, name = "Item " + id })
.RequireAuthorization("read-items");
app.MapPost("/api/items", (CreateItemRequest req) => new { id = Guid.NewGuid() })
.RequireAuthorization("write-items");
// Activate the bridge (discovers and registers endpoints)
app.UseStellaRouterBridge();
app.Run();
- (Optional) Add YAML overrides for security hardening:
# router.yaml
endpoints:
- path: "/api/admin/**"
requiringClaims:
- type: "Role"
value: "admin"
timeoutMs: 60000
- Test via Gateway
- Deploy to production
Pros:
- Zero code changes to existing endpoints
- Automatic authorization metadata extraction
- Full ASP.NET pipeline execution (filters, binding, validation)
- Deterministic endpoint ordering
- YAML overrides for security hardening without code changes
- Supports both minimal APIs and controllers
Cons:
- Slightly more runtime overhead (HttpContext synthesis)
- Some ASP.NET features not supported (see limitations below)
Authorization Mapping
The bridge automatically extracts authorization requirements:
| ASP.NET Source | Router Mapping |
|---|---|
[Authorize(Policy = "policy-name")] |
Resolved via IAuthorizationPolicyProvider → RequiringClaims |
[Authorize(Roles = "admin,user")] |
ClaimRequirement(Role, admin), ClaimRequirement(Role, user) |
.RequireAuthorization("policy") |
Resolved via policy provider |
.RequireAuthorization(new AuthorizeAttribute { Roles = "..." }) |
Direct role mapping |
[AllowAnonymous] |
Empty RequiringClaims (public endpoint) |
Authorization Mapping Strategies
Configure how the bridge handles authorization:
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "my-service";
options.Version = "1.0.0";
options.Region = "us-east-1";
// Strategy options:
// - YamlOnly: YAML claims replace code claims entirely
// - AspNetMetadataOnly: Code claims only, ignore YAML (default)
// - Hybrid: Merge code + YAML claims by type
options.AuthorizationMappingStrategy = AuthorizationMappingStrategy.Hybrid;
// Behavior when endpoint has no authorization:
// - RequireExplicit: Fail validation (secure default)
// - AllowAuthenticated: Require authentication but no specific claims
// - WarnAndAllow: Log warning, allow request (dev only)
options.MissingAuthorizationBehavior = MissingAuthorizationBehavior.RequireExplicit;
});
Route Filtering
Control which endpoints are bridged:
builder.Services.AddStellaRouterBridge(options =>
{
options.ServiceName = "my-service";
options.Version = "1.0.0";
options.Region = "us-east-1";
// Include only /api/* endpoints
options.IncludePathPatterns = ["/api/**"];
// Exclude internal endpoints
options.ExcludePathPatterns = ["/internal/**", "/metrics", "/health/**"];
});
Limitations
The ASP.NET Endpoint Bridge does not support:
| Feature | Alternative |
|---|---|
| SignalR/WebSocket | Use direct HTTP for real-time features |
| gRPC endpoints | Use separate gRPC channel |
| Streaming request bodies | Use IRawStellaEndpoint for streaming |
Custom route constraints ({id:guid}) |
Constraints execute at dispatch, but not in discovery |
| Header/query-based API versioning | Use path-based versioning (/api/v1/...) |
Controller to Handler Mapping
Before (ASP.NET Controller)
[ApiController]
[Route("api/invoices")]
public class InvoicesController : ControllerBase
{
private readonly IInvoiceService _service;
[HttpPost]
[Authorize(Roles = "billing-admin")]
public async Task<IActionResult> Create(
[FromBody] CreateInvoiceRequest request,
CancellationToken ct)
{
var invoice = await _service.CreateAsync(request);
return Ok(new { invoice.Id });
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id)
{
var invoice = await _service.GetAsync(id);
if (invoice == null) return NotFound();
return Ok(invoice);
}
}
After (Microservice Handler)
// Handler for POST /api/invoices
[StellaEndpoint("POST", "/api/invoices", RequiredClaims = ["invoices:write"])]
public sealed class CreateInvoiceEndpoint : IStellaEndpoint<CreateInvoiceRequest, CreateInvoiceResponse>
{
private readonly IInvoiceService _service;
public CreateInvoiceEndpoint(IInvoiceService service) => _service = service;
public async Task<CreateInvoiceResponse> HandleAsync(
CreateInvoiceRequest request,
CancellationToken ct)
{
var invoice = await _service.CreateAsync(request, ct);
return new CreateInvoiceResponse { InvoiceId = invoice.Id };
}
}
// Handler for GET /api/invoices/{id}
[StellaEndpoint("GET", "/api/invoices/{id}", RequiredClaims = ["invoices:read"])]
public sealed class GetInvoiceEndpoint : IStellaEndpoint<GetInvoiceRequest, GetInvoiceResponse>
{
private readonly IInvoiceService _service;
public GetInvoiceEndpoint(IInvoiceService service) => _service = service;
public async Task<GetInvoiceResponse> HandleAsync(
GetInvoiceRequest request,
CancellationToken ct)
{
var invoice = await _service.GetAsync(request.Id, ct);
return new GetInvoiceResponse
{
InvoiceId = invoice?.Id,
Found = invoice != null
};
}
}
CancellationToken Wiring
This is the #1 source of migration bugs. Every async operation must receive and respect the cancellation token.
Checklist
For each migrated handler, verify:
- Handler accepts CancellationToken parameter (automatic with IStellaEndpoint)
- Token passed to all database calls
- Token passed to all HTTP client calls
- Token passed to all file I/O operations
- Long-running loops check
ct.IsCancellationRequested - Token passed to
Task.Delay,WaitAsync, etc.
Example: Before (missing tokens)
public async Task<Invoice> CreateAsync(CreateInvoiceRequest request)
{
var invoice = new Invoice(request);
await _db.Invoices.AddAsync(invoice); // Missing token!
await _db.SaveChangesAsync(); // Missing token!
await _notifier.SendAsync(invoice); // Missing token!
return invoice;
}
Example: After (proper wiring)
public async Task<Invoice> CreateAsync(CreateInvoiceRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var invoice = new Invoice(request);
await _db.Invoices.AddAsync(invoice, ct);
await _db.SaveChangesAsync(ct);
await _notifier.SendAsync(invoice, ct);
return invoice;
}
Streaming Migration
File Upload: Before
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
using var stream = file.OpenReadStream();
await _storage.SaveAsync(stream);
return Ok();
}
File Upload: After
[StellaEndpoint("POST", "/upload", SupportsStreaming = true)]
public sealed class UploadEndpoint : IRawStellaEndpoint
{
private readonly IStorageService _storage;
public UploadEndpoint(IStorageService storage) => _storage = storage;
public async Task<RawResponse> HandleAsync(RawRequestContext ctx, CancellationToken ct)
{
// ctx.Body is already a stream - no buffering needed
var path = await _storage.SaveAsync(ctx.Body, ct);
return RawResponse.Ok($"{{\"path\":\"{path}\"}}");
}
}
File Download: Before
[HttpGet("download/{id}")]
public async Task<IActionResult> Download(string id)
{
var stream = await _storage.GetAsync(id);
return File(stream, "application/octet-stream");
}
File Download: After
[StellaEndpoint("GET", "/download/{id}", SupportsStreaming = true)]
public sealed class DownloadEndpoint : IRawStellaEndpoint
{
private readonly IStorageService _storage;
public DownloadEndpoint(IStorageService storage) => _storage = storage;
public async Task<RawResponse> HandleAsync(RawRequestContext ctx, CancellationToken ct)
{
var id = ctx.PathParameters["id"];
var stream = await _storage.GetAsync(id, ct);
return RawResponse.Stream(stream, "application/octet-stream");
}
}
Authorization Migration
Before: [Authorize] Attribute
[Authorize(Roles = "admin,billing-manager")]
public async Task<IActionResult> Delete(string id) { ... }
After: RequiredClaims
[StellaEndpoint("DELETE", "/invoices/{id}", RequiredClaims = ["invoices:delete"])]
public sealed class DeleteInvoiceEndpoint : IStellaEndpoint<...> { ... }
Claims are configured in Authority and enforced by the Gateway's AuthorizationMiddleware.
Migration Checklist Template
Use this checklist for each service migration:
# Migration Checklist: [ServiceName]
## Inventory
- [ ] List all HTTP routes (Method + Path)
- [ ] Identify streaming endpoints
- [ ] Identify authorization requirements
- [ ] Document external dependencies
## Preparation
- [ ] Add StellaOps.Microservice package
- [ ] Add StellaOps.Router.Transport.* package(s)
- [ ] Configure router connection in Program.cs
- [ ] Set up local gateway for testing
## Per-Route Migration
For each route:
- [ ] Create [StellaEndpoint] handler class
- [ ] Define request/response record types
- [ ] Map path parameters
- [ ] Wire CancellationToken throughout
- [ ] Convert to IRawStellaEndpoint if streaming
- [ ] Add RequiredClaims
- [ ] Write unit tests
- [ ] Write integration tests
## Cutover
- [ ] Deploy alongside existing WebService
- [ ] Verify via router routing
- [ ] Shift percentage of traffic
- [ ] Monitor for errors
- [ ] Full cutover
- [ ] Remove WebService HTTP listeners
## Cleanup
- [ ] Remove unused controller code
- [ ] Remove HTTP pipeline configuration
- [ ] Update OpenAPI documentation
- [ ] Update client SDKs
Service Inventory
| Module | WebService Project | Priority | Complexity | Notes |
|---|---|---|---|---|
| Gateway | StellaOps.Gateway.WebService | N/A | N/A | IS the router |
| Concelier | StellaOps.Concelier.WebService | High | Medium | Advisory ingestion |
| Scanner | StellaOps.Scanner.WebService | High | High | Streaming scans |
| Attestor | StellaOps.Attestor.WebService | Medium | Medium | Attestation gen |
| Excititor | StellaOps.Excititor.WebService | Medium | Low | VEX processing |
| Orchestrator | StellaOps.Orchestrator.WebService | Medium | Medium | Job coordination |
| Scheduler | StellaOps.Scheduler.WebService | Low | Low | Job scheduling |
| Notify | StellaOps.Notify.WebService | Low | Low | Notifications |
| Notifier | StellaOps.Notifier.WebService | Low | Low | Alert dispatch |
| Signer | StellaOps.Signer.WebService | Medium | Low | Crypto signing |
| Findings | StellaOps.Findings.Ledger.WebService | Medium | Medium | Results storage |
| EvidenceLocker | StellaOps.EvidenceLocker.WebService | Low | Medium | Blob storage |
| ExportCenter | StellaOps.ExportCenter.WebService | Low | Medium | Report generation |
| IssuerDirectory | StellaOps.IssuerDirectory.WebService | Low | Low | Issuer lookup |
| PacksRegistry | StellaOps.PacksRegistry.WebService | Low | Low | Pack management |
| RiskEngine | StellaOps.RiskEngine.WebService | Medium | Medium | Risk calculation |
| TaskRunner | StellaOps.TaskRunner.WebService | Low | Medium | Task execution |
| TimelineIndexer | StellaOps.TimelineIndexer.WebService | Low | Low | Event indexing |
| AdvisoryAI | StellaOps.AdvisoryAI.WebService | Low | Medium | AI assistance |
Testing During Migration
Unit Tests
Test handlers in isolation using mocked dependencies:
[Fact]
public async Task CreateInvoice_ValidRequest_ReturnsInvoiceId()
{
// Arrange
var mockService = new Mock<IInvoiceService>();
mockService.Setup(s => s.CreateAsync(It.IsAny<CreateInvoiceRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Invoice { Id = "INV-123" });
var endpoint = new CreateInvoiceEndpoint(mockService.Object);
// Act
var response = await endpoint.HandleAsync(
new CreateInvoiceRequest { Amount = 100 },
CancellationToken.None);
// Assert
response.InvoiceId.Should().Be("INV-123");
}
Integration Tests
Use WebApplicationFactory for the Gateway and actual microservice instances:
public sealed class InvoiceTests : IClassFixture<GatewayFixture>
{
private readonly GatewayFixture _fixture;
[Fact]
public async Task CreateAndGetInvoice_WorksEndToEnd()
{
var createResponse = await _fixture.Client.PostAsJsonAsync("/api/invoices",
new { Amount = 100 });
createResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var created = await createResponse.Content.ReadFromJsonAsync<CreateInvoiceResponse>();
var getResponse = await _fixture.Client.GetAsync($"/api/invoices/{created.InvoiceId}");
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
Common Migration Issues
1. Missing CancellationToken Propagation
Symptom: Requests continue processing after client disconnects.
Fix: Pass CancellationToken to all async operations.
2. IFormFile Not Available
Symptom: Compilation error on IFormFile parameter.
Fix: Convert to IRawStellaEndpoint for streaming.
3. HttpContext Not Available
Symptom: Code references HttpContext for headers, claims.
Fix: Use RawRequestContext for raw endpoints, or inject claims via Authority.
4. Return Type Mismatch
Symptom: Handler returns IActionResult.
Fix: Define proper response record type, return that instead.
5. Route Parameter Not Extracted
Symptom: Path parameters like {id} not populated.
Fix: For IStellaEndpoint, add property to request type. For IRawStellaEndpoint, use ctx.PathParameters["id"].
Next Steps
- Choose a low-risk service for pilot migration (Scheduler recommended)
- Follow the Migration Checklist
- Document lessons learned
- Proceed with higher-priority services
- Eventually merge all to use router exclusively
See Also
- Router Architecture - System specification
- Schema Validation - JSON Schema validation
- OpenAPI Aggregation - OpenAPI document generation