Files
git.stella-ops.org/docs/modules/router/migration-guide.md
master cc69d332e3
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add unit tests for RabbitMq and Udp transport servers and clients
- Implemented comprehensive unit tests for RabbitMqTransportServer, covering constructor, disposal, connection management, event handlers, and exception handling.
- Added configuration tests for RabbitMqTransportServer to validate SSL, durable queues, auto-recovery, and custom virtual host options.
- Created unit tests for UdpFrameProtocol, including frame parsing and serialization, header size validation, and round-trip data preservation.
- Developed tests for UdpTransportClient, focusing on connection handling, event subscriptions, and exception scenarios.
- Established tests for UdpTransportServer, ensuring proper start/stop behavior, connection state management, and event handling.
- Included tests for UdpTransportOptions to verify default values and modification capabilities.
- Enhanced service registration tests for Udp transport services in the dependency injection container.
2025-12-05 19:01:12 +02:00

15 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:

  1. Router infrastructure is deployed (Gateway, transports)
  2. Authority is configured with endpoint claims
  3. 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:

  1. Add StellaOps.Microservice package reference
  2. Create handler classes for each HTTP route
  3. Handlers call existing service layer
  4. Register with router alongside HTTP
  5. Test via router
  6. Shift traffic gradually
  7. 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:

  1. Extract domain logic to .Domain library
  2. Create new .Microservice project
  3. Implement handlers using domain library
  4. Deploy alongside WebService
  5. Shift traffic to router
  6. Deprecate WebService

Pros:

  • Clean architecture
  • No legacy code in new project
  • Clear separation of concerns

Cons:

  • More upfront work
  • Requires domain extraction

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

  1. Choose a low-risk service for pilot migration (Scheduler recommended)
  2. Follow the Migration Checklist
  3. Document lessons learned
  4. Proceed with higher-priority services
  5. Eventually merge all to use router exclusively

See Also