sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -0,0 +1,680 @@
// -----------------------------------------------------------------------------
// RuntimeAgentController.cs
// Sprint: SPRINT_20260109_009_004
// Task: API endpoints for runtime agent registration, heartbeat, and facts ingestion
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Signals.RuntimeAgent;
namespace StellaOps.Signals.Api;
/// <summary>
/// API controller for runtime agent management and facts ingestion.
/// Provides endpoints for agent registration, heartbeat, and runtime observation ingestion.
/// </summary>
[ApiController]
[Route("api/v1/agents")]
[Produces("application/json")]
public sealed class RuntimeAgentController : ControllerBase
{
private readonly IAgentRegistrationService _registrationService;
private readonly IRuntimeFactsIngest _factsIngestService;
private readonly ILogger<RuntimeAgentController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RuntimeAgentController"/> class.
/// </summary>
public RuntimeAgentController(
IAgentRegistrationService registrationService,
IRuntimeFactsIngest factsIngestService,
ILogger<RuntimeAgentController> logger)
{
_registrationService = registrationService ?? throw new ArgumentNullException(nameof(registrationService));
_factsIngestService = factsIngestService ?? throw new ArgumentNullException(nameof(factsIngestService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Registers a new runtime agent.
/// </summary>
/// <param name="request">Registration request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Agent registration response with agent ID.</returns>
[HttpPost("register")]
[ProducesResponseType(typeof(AgentRegistrationApiResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AgentRegistrationApiResponse>> Register(
[FromBody] RegisterAgentApiRequest request,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.AgentId))
{
return BadRequest(new ProblemDetails
{
Title = "Invalid agent ID",
Detail = "The 'agentId' field is required.",
Status = StatusCodes.Status400BadRequest,
});
}
if (string.IsNullOrWhiteSpace(request.Hostname))
{
return BadRequest(new ProblemDetails
{
Title = "Invalid hostname",
Detail = "The 'hostname' field is required.",
Status = StatusCodes.Status400BadRequest,
});
}
_logger.LogInformation(
"Registering agent {AgentId}, hostname {Hostname}, platform {Platform}",
request.AgentId, request.Hostname, request.Platform);
try
{
var registrationRequest = new AgentRegistrationRequest
{
AgentId = request.AgentId,
Platform = request.Platform,
Hostname = request.Hostname,
ContainerId = request.ContainerId,
KubernetesNamespace = request.KubernetesNamespace,
KubernetesPodName = request.KubernetesPodName,
ApplicationName = request.ApplicationName,
ProcessId = request.ProcessId,
AgentVersion = request.AgentVersion ?? "1.0.0",
InitialPosture = request.InitialPosture,
Tags = request.Tags?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
};
var registration = await _registrationService.RegisterAsync(registrationRequest, ct);
var response = MapToApiResponse(registration);
return CreatedAtAction(
nameof(GetAgent),
new { agentId = registration.AgentId },
response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error registering agent {AgentId}", request.AgentId);
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "Internal server error",
Detail = "An error occurred while registering the agent.",
Status = StatusCodes.Status500InternalServerError,
});
}
}
/// <summary>
/// Records an agent heartbeat.
/// </summary>
/// <param name="agentId">Agent ID.</param>
/// <param name="request">Heartbeat request with state and statistics.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Heartbeat response with commands.</returns>
[HttpPost("{agentId}/heartbeat")]
[ProducesResponseType(typeof(AgentHeartbeatApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<AgentHeartbeatApiResponse>> Heartbeat(
string agentId,
[FromBody] HeartbeatApiRequest request,
CancellationToken ct = default)
{
_logger.LogDebug("Heartbeat received from agent {AgentId}", agentId);
try
{
var heartbeatRequest = new AgentHeartbeatRequest
{
AgentId = agentId,
State = request.State,
Posture = request.Posture,
Statistics = request.Statistics,
};
var response = await _registrationService.HeartbeatAsync(heartbeatRequest, ct);
return Ok(new AgentHeartbeatApiResponse
{
Continue = response.Continue,
NewPosture = response.NewPosture,
Command = response.Command,
});
}
catch (KeyNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Agent not found",
Detail = $"No agent found with ID '{agentId}'.",
Status = StatusCodes.Status404NotFound,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording heartbeat for agent {AgentId}", agentId);
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "Internal server error",
Detail = "An error occurred while recording the heartbeat.",
Status = StatusCodes.Status500InternalServerError,
});
}
}
/// <summary>
/// Gets agent details.
/// </summary>
/// <param name="agentId">Agent ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Agent details.</returns>
[HttpGet("{agentId}")]
[ProducesResponseType(typeof(AgentRegistrationApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<AgentRegistrationApiResponse>> GetAgent(
string agentId,
CancellationToken ct = default)
{
var registration = await _registrationService.GetAsync(agentId, ct);
if (registration == null)
{
return NotFound(new ProblemDetails
{
Title = "Agent not found",
Detail = $"No agent found with ID '{agentId}'.",
Status = StatusCodes.Status404NotFound,
});
}
return Ok(MapToApiResponse(registration));
}
/// <summary>
/// Lists all registered agents.
/// </summary>
/// <param name="platform">Optional platform filter.</param>
/// <param name="healthyOnly">Only return healthy agents.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of agents.</returns>
[HttpGet]
[ProducesResponseType(typeof(AgentListApiResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<AgentListApiResponse>> ListAgents(
[FromQuery(Name = "platform")] RuntimePlatform? platform = null,
[FromQuery(Name = "healthy_only")] bool healthyOnly = false,
CancellationToken ct = default)
{
IReadOnlyList<AgentRegistration> agents;
if (healthyOnly)
{
agents = await _registrationService.ListHealthyAsync(ct);
}
else if (platform.HasValue)
{
agents = await _registrationService.ListByPlatformAsync(platform.Value, ct);
}
else
{
agents = await _registrationService.ListAsync(ct);
}
return Ok(new AgentListApiResponse
{
Agents = agents.Select(MapToApiResponse).ToList(),
TotalCount = agents.Count,
});
}
/// <summary>
/// Deregisters an agent.
/// </summary>
/// <param name="agentId">Agent ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>No content on success.</returns>
[HttpDelete("{agentId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Unregister(
string agentId,
CancellationToken ct = default)
{
_logger.LogInformation("Unregistering agent {AgentId}", agentId);
try
{
await _registrationService.UnregisterAsync(agentId, ct);
return NoContent();
}
catch (KeyNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Agent not found",
Detail = $"No agent found with ID '{agentId}'.",
Status = StatusCodes.Status404NotFound,
});
}
}
/// <summary>
/// Sends a command to an agent.
/// </summary>
/// <param name="agentId">Agent ID.</param>
/// <param name="request">Command request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Accepted.</returns>
[HttpPost("{agentId}/commands")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> SendCommand(
string agentId,
[FromBody] CommandApiRequest request,
CancellationToken ct = default)
{
_logger.LogInformation(
"Sending command {Command} to agent {AgentId}",
request.Command, agentId);
try
{
await _registrationService.SendCommandAsync(agentId, request.Command, ct);
return Accepted();
}
catch (KeyNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Agent not found",
Detail = $"No agent found with ID '{agentId}'.",
Status = StatusCodes.Status404NotFound,
});
}
}
/// <summary>
/// Updates agent posture.
/// </summary>
/// <param name="agentId">Agent ID.</param>
/// <param name="request">Posture update request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>No content on success.</returns>
[HttpPatch("{agentId}/posture")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdatePosture(
string agentId,
[FromBody] PostureUpdateRequest request,
CancellationToken ct = default)
{
_logger.LogInformation(
"Updating posture of agent {AgentId} to {Posture}",
agentId, request.Posture);
try
{
await _registrationService.UpdatePostureAsync(agentId, request.Posture, ct);
return NoContent();
}
catch (KeyNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Agent not found",
Detail = $"No agent found with ID '{agentId}'.",
Status = StatusCodes.Status404NotFound,
});
}
}
private static AgentRegistrationApiResponse MapToApiResponse(AgentRegistration registration)
{
return new AgentRegistrationApiResponse
{
AgentId = registration.AgentId,
Platform = registration.Platform,
Hostname = registration.Hostname,
ContainerId = registration.ContainerId,
KubernetesNamespace = registration.KubernetesNamespace,
KubernetesPodName = registration.KubernetesPodName,
ApplicationName = registration.ApplicationName,
ProcessId = registration.ProcessId,
AgentVersion = registration.AgentVersion,
RegisteredAt = registration.RegisteredAt,
LastHeartbeat = registration.LastHeartbeat,
State = registration.State,
Posture = registration.Posture,
Tags = registration.Tags.ToDictionary(kv => kv.Key, kv => kv.Value),
};
}
}
/// <summary>
/// API controller for runtime facts ingestion.
/// </summary>
[ApiController]
[Route("api/v1/agents/{agentId}/facts")]
[Produces("application/json")]
public sealed class RuntimeFactsController : ControllerBase
{
private readonly IRuntimeFactsIngest _factsIngestService;
private readonly ILogger<RuntimeFactsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RuntimeFactsController"/> class.
/// </summary>
public RuntimeFactsController(
IRuntimeFactsIngest factsIngestService,
ILogger<RuntimeFactsController> logger)
{
_factsIngestService = factsIngestService ?? throw new ArgumentNullException(nameof(factsIngestService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Ingests a batch of runtime method events.
/// </summary>
/// <param name="agentId">Agent ID.</param>
/// <param name="request">Batch of events.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Ingestion result.</returns>
[HttpPost]
[ProducesResponseType(typeof(FactsIngestApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<FactsIngestApiResponse>> IngestFacts(
string agentId,
[FromBody] FactsIngestApiRequest request,
CancellationToken ct = default)
{
if (request.Events == null || request.Events.Count == 0)
{
return BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "At least one event is required.",
Status = StatusCodes.Status400BadRequest,
});
}
_logger.LogDebug(
"Ingesting {EventCount} events from agent {AgentId}",
request.Events.Count, agentId);
try
{
var events = request.Events.Select(e => new RuntimeMethodEvent
{
EventId = e.EventId ?? Guid.NewGuid().ToString("N"),
SymbolId = e.SymbolId,
MethodName = e.MethodName,
TypeName = e.TypeName,
AssemblyOrModule = e.AssemblyOrModule,
Timestamp = e.Timestamp,
Kind = e.Kind,
ContainerId = e.ContainerId,
ProcessId = e.ProcessId,
ThreadId = e.ThreadId,
CallDepth = e.CallDepth,
DurationMicroseconds = e.DurationMicroseconds,
Context = e.Context?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
});
var result = await _factsIngestService.IngestBatchAsync(agentId, events, ct);
return Ok(new FactsIngestApiResponse
{
AcceptedCount = result.AcceptedCount,
RejectedCount = result.RejectedCount,
AggregatedSymbols = result.AggregatedSymbols,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error ingesting facts from agent {AgentId}", agentId);
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "Internal server error",
Detail = "An error occurred while ingesting facts.",
Status = StatusCodes.Status500InternalServerError,
});
}
}
}
#region API DTOs
/// <summary>
/// Agent registration API request.
/// </summary>
public sealed record RegisterAgentApiRequest
{
/// <summary>Unique agent identifier (generated by agent).</summary>
[Required]
public required string AgentId { get; init; }
/// <summary>Target platform.</summary>
public RuntimePlatform Platform { get; init; } = RuntimePlatform.DotNet;
/// <summary>Hostname where agent is running.</summary>
[Required]
public required string Hostname { get; init; }
/// <summary>Container ID if running in container.</summary>
public string? ContainerId { get; init; }
/// <summary>Kubernetes namespace if running in K8s.</summary>
public string? KubernetesNamespace { get; init; }
/// <summary>Kubernetes pod name if running in K8s.</summary>
public string? KubernetesPodName { get; init; }
/// <summary>Target application name.</summary>
public string? ApplicationName { get; init; }
/// <summary>Target process ID.</summary>
public int? ProcessId { get; init; }
/// <summary>Agent version.</summary>
public string? AgentVersion { get; init; }
/// <summary>Initial posture.</summary>
public RuntimePosture InitialPosture { get; init; } = RuntimePosture.Sampled;
/// <summary>Tags for grouping/filtering.</summary>
public Dictionary<string, string>? Tags { get; init; }
}
/// <summary>
/// Agent registration API response.
/// </summary>
public sealed record AgentRegistrationApiResponse
{
/// <summary>Agent ID.</summary>
public required string AgentId { get; init; }
/// <summary>Platform.</summary>
public required RuntimePlatform Platform { get; init; }
/// <summary>Hostname.</summary>
public required string Hostname { get; init; }
/// <summary>Container ID.</summary>
public string? ContainerId { get; init; }
/// <summary>Kubernetes namespace.</summary>
public string? KubernetesNamespace { get; init; }
/// <summary>Kubernetes pod name.</summary>
public string? KubernetesPodName { get; init; }
/// <summary>Application name.</summary>
public string? ApplicationName { get; init; }
/// <summary>Process ID.</summary>
public int? ProcessId { get; init; }
/// <summary>Agent version.</summary>
public required string AgentVersion { get; init; }
/// <summary>Registered timestamp.</summary>
public required DateTimeOffset RegisteredAt { get; init; }
/// <summary>Last heartbeat timestamp.</summary>
public DateTimeOffset LastHeartbeat { get; init; }
/// <summary>State.</summary>
public AgentState State { get; init; }
/// <summary>Posture.</summary>
public RuntimePosture Posture { get; init; }
/// <summary>Tags.</summary>
public Dictionary<string, string>? Tags { get; init; }
}
/// <summary>
/// Agent heartbeat API request.
/// </summary>
public sealed record HeartbeatApiRequest
{
/// <summary>Current agent state.</summary>
public required AgentState State { get; init; }
/// <summary>Current posture.</summary>
public required RuntimePosture Posture { get; init; }
/// <summary>Statistics snapshot.</summary>
public AgentStatistics? Statistics { get; init; }
}
/// <summary>
/// Agent heartbeat API response.
/// </summary>
public sealed record AgentHeartbeatApiResponse
{
/// <summary>Whether the agent should continue.</summary>
public bool Continue { get; init; } = true;
/// <summary>New posture if changed.</summary>
public RuntimePosture? NewPosture { get; init; }
/// <summary>Command to execute.</summary>
public AgentCommand? Command { get; init; }
}
/// <summary>
/// Agent list API response.
/// </summary>
public sealed record AgentListApiResponse
{
/// <summary>List of agents.</summary>
public required IReadOnlyList<AgentRegistrationApiResponse> Agents { get; init; }
/// <summary>Total count.</summary>
public required int TotalCount { get; init; }
}
/// <summary>
/// Command API request.
/// </summary>
public sealed record CommandApiRequest
{
/// <summary>Command to send.</summary>
[Required]
public required AgentCommand Command { get; init; }
}
/// <summary>
/// Posture update request.
/// </summary>
public sealed record PostureUpdateRequest
{
/// <summary>New posture.</summary>
[Required]
public required RuntimePosture Posture { get; init; }
}
/// <summary>
/// Facts ingest API request.
/// </summary>
public sealed record FactsIngestApiRequest
{
/// <summary>Events to ingest.</summary>
[Required]
public required IReadOnlyList<RuntimeEventApiDto> Events { get; init; }
}
/// <summary>
/// Runtime event API DTO.
/// </summary>
public sealed record RuntimeEventApiDto
{
/// <summary>Event ID (optional, will be generated if not provided).</summary>
public string? EventId { get; init; }
/// <summary>Symbol ID.</summary>
[Required]
public required string SymbolId { get; init; }
/// <summary>Method name.</summary>
[Required]
public required string MethodName { get; init; }
/// <summary>Type name.</summary>
[Required]
public required string TypeName { get; init; }
/// <summary>Assembly or module.</summary>
[Required]
public required string AssemblyOrModule { get; init; }
/// <summary>Timestamp.</summary>
[Required]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Event kind.</summary>
public RuntimeEventKind Kind { get; init; } = RuntimeEventKind.Sample;
/// <summary>Container ID.</summary>
public string? ContainerId { get; init; }
/// <summary>Process ID.</summary>
public int? ProcessId { get; init; }
/// <summary>Thread ID.</summary>
public string? ThreadId { get; init; }
/// <summary>Call depth.</summary>
public int? CallDepth { get; init; }
/// <summary>Duration in microseconds.</summary>
public long? DurationMicroseconds { get; init; }
/// <summary>Additional context.</summary>
public Dictionary<string, string>? Context { get; init; }
}
/// <summary>
/// Facts ingest API response.
/// </summary>
public sealed record FactsIngestApiResponse
{
/// <summary>Number of accepted events.</summary>
public required int AcceptedCount { get; init; }
/// <summary>Number of rejected events.</summary>
public required int RejectedCount { get; init; }
/// <summary>Number of aggregated symbols.</summary>
public required int AggregatedSymbols { get; init; }
}
#endregion