up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-14 15:50:38 +02:00
parent f1a39c4ce3
commit 233873f620
249 changed files with 29746 additions and 154 deletions

View File

@@ -0,0 +1,126 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Agent.Configuration;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Zastava.Agent.Backend;
/// <summary>
/// HTTP client for sending runtime events to the Scanner backend.
/// </summary>
internal interface IRuntimeEventsClient
{
Task<RuntimeEventsSubmitResult> SubmitAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
CancellationToken cancellationToken);
}
internal sealed class RuntimeEventsSubmitResult
{
public bool Success { get; init; }
public int Accepted { get; init; }
public int Duplicates { get; init; }
public bool RateLimited { get; init; }
public TimeSpan? RetryAfter { get; init; }
public string? ErrorMessage { get; init; }
}
internal sealed class RuntimeEventsClient : IRuntimeEventsClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
private readonly ILogger<RuntimeEventsClient> _logger;
public RuntimeEventsClient(
HttpClient httpClient,
IOptionsMonitor<ZastavaAgentOptions> options,
ILogger<RuntimeEventsClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RuntimeEventsSubmitResult> SubmitAsync(
IReadOnlyList<RuntimeEventEnvelope> envelopes,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(envelopes);
if (envelopes.Count == 0)
{
return new RuntimeEventsSubmitResult { Success = true, Accepted = 0 };
}
var backend = _options.CurrentValue.Backend;
var path = backend.EventsPath;
try
{
var request = new RuntimeEventsSubmitRequest
{
BatchId = Guid.NewGuid().ToString("N"),
Events = envelopes.ToArray()
};
var response = await _httpClient.PostAsJsonAsync(path, request, JsonOptions, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60);
_logger.LogWarning("Runtime events rate limited; retry after {RetryAfter}", retryAfter);
return new RuntimeEventsSubmitResult
{
Success = false,
RateLimited = true,
RetryAfter = retryAfter
};
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<RuntimeEventsSubmitResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return new RuntimeEventsSubmitResult
{
Success = true,
Accepted = result?.Accepted ?? envelopes.Count,
Duplicates = result?.Duplicates ?? 0
};
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to submit runtime events to backend: {StatusCode}", ex.StatusCode);
return new RuntimeEventsSubmitResult
{
Success = false,
ErrorMessage = ex.Message
};
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Runtime events submission timed out");
return new RuntimeEventsSubmitResult
{
Success = false,
ErrorMessage = "Request timed out"
};
}
}
}
internal sealed class RuntimeEventsSubmitRequest
{
public string? BatchId { get; init; }
public RuntimeEventEnvelope[] Events { get; init; } = Array.Empty<RuntimeEventEnvelope>();
}
internal sealed class RuntimeEventsSubmitResponse
{
public int Accepted { get; init; }
public int Duplicates { get; init; }
}

View File

@@ -0,0 +1,208 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.Zastava.Core.Configuration;
namespace StellaOps.Zastava.Agent.Configuration;
/// <summary>
/// Agent-specific configuration for VM/bare-metal Docker deployments.
/// </summary>
public sealed class ZastavaAgentOptions
{
public const string SectionName = "zastava:agent";
private const string DefaultDockerSocket = "unix:///var/run/docker.sock";
/// <summary>
/// Logical node identifier emitted with runtime events (defaults to environment hostname).
/// </summary>
[Required(AllowEmptyStrings = false)]
public string NodeName { get; set; } =
Environment.GetEnvironmentVariable("ZASTAVA_NODE_NAME")
?? Environment.MachineName;
/// <summary>
/// Docker socket endpoint (unix socket or named pipe).
/// </summary>
[Required(AllowEmptyStrings = false)]
public string DockerEndpoint { get; set; } =
Environment.GetEnvironmentVariable("DOCKER_HOST")
?? (OperatingSystem.IsWindows()
? "npipe://./pipe/docker_engine"
: DefaultDockerSocket);
/// <summary>
/// Connection timeout for Docker socket.
/// </summary>
[Range(typeof(TimeSpan), "00:00:01", "00:01:00")]
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum number of runtime events held in the in-memory buffer.
/// </summary>
[Range(16, 65536)]
public int MaxInMemoryBuffer { get; set; } = 2048;
/// <summary>
/// Number of runtime events drained in one batch by downstream publishers.
/// </summary>
[Range(1, 512)]
public int PublishBatchSize { get; set; } = 32;
/// <summary>
/// Maximum interval (seconds) that events may remain buffered before forcing a publish.
/// </summary>
[Range(typeof(double), "0.1", "30")]
public double PublishFlushIntervalSeconds { get; set; } = 2;
/// <summary>
/// Directory used for disk-backed runtime event buffering.
/// </summary>
[Required(AllowEmptyStrings = false)]
public string EventBufferPath { get; set; } = OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "zastava-agent", "runtime-events")
: Path.Combine("/var/lib/zastava-agent", "runtime-events");
/// <summary>
/// Maximum on-disk bytes retained for buffered runtime events.
/// </summary>
[Range(typeof(long), "1048576", "1073741824")]
public long MaxDiskBufferBytes { get; set; } = 64 * 1024 * 1024; // 64 MiB
/// <summary>
/// Connectivity/backoff settings applied when Docker endpoint fails temporarily.
/// </summary>
[Required]
public AgentBackoffOptions Backoff { get; set; } = new();
/// <summary>
/// Scanner backend configuration for event ingestion.
/// </summary>
[Required]
public ZastavaAgentBackendOptions Backend { get; set; } = new();
/// <summary>
/// Root path for accessing host process information (defaults to /proc on Linux).
/// </summary>
[Required(AllowEmptyStrings = false)]
public string ProcRootPath { get; set; } = OperatingSystem.IsWindows() ? "C:\\Windows\\System32" : "/proc";
/// <summary>
/// Maximum number of loaded libraries captured per process.
/// </summary>
[Range(8, 4096)]
public int MaxTrackedLibraries { get; set; } = 256;
/// <summary>
/// Maximum size (in bytes) of a library file to hash when collecting loaded libraries.
/// </summary>
[Range(typeof(long), "1024", "1073741824")]
public long MaxLibraryBytes { get; set; } = 33554432; // 32 MiB
/// <summary>
/// Health check server configuration.
/// </summary>
[Required]
public AgentHealthCheckOptions HealthCheck { get; set; } = new();
/// <summary>
/// Docker event filter configuration.
/// </summary>
[Required]
public DockerEventFilterOptions EventFilters { get; set; } = new();
}
public sealed class ZastavaAgentBackendOptions
{
/// <summary>
/// Base address for Scanner WebService runtime APIs.
/// </summary>
[Required]
public Uri BaseAddress { get; init; } = new("https://scanner.internal");
/// <summary>
/// Runtime events ingestion endpoint path.
/// </summary>
[Required(AllowEmptyStrings = false)]
public string EventsPath { get; init; } = "/api/v1/runtime/events";
/// <summary>
/// Request timeout for backend calls in seconds.
/// </summary>
[Range(typeof(double), "1", "120")]
public double RequestTimeoutSeconds { get; init; } = 5;
/// <summary>
/// Allows plain HTTP endpoints when true (default false for safety).
/// </summary>
public bool AllowInsecureHttp { get; init; }
}
public sealed class AgentBackoffOptions
{
/// <summary>
/// Initial backoff delay applied after the first failure.
/// </summary>
[Range(typeof(TimeSpan), "00:00:01", "00:05:00")]
public TimeSpan Initial { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Maximum backoff delay after repeated failures.
/// </summary>
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
public TimeSpan Max { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Jitter ratio applied to the computed delay (0 disables jitter).
/// </summary>
[Range(0.0, 0.5)]
public double JitterRatio { get; set; } = 0.2;
}
public sealed class AgentHealthCheckOptions
{
/// <summary>
/// Enable HTTP health check endpoints (/healthz, /readyz).
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Port for health check HTTP server.
/// </summary>
[Range(1, 65535)]
public int Port { get; set; } = 8080;
/// <summary>
/// Bind address for health check server.
/// </summary>
[Required(AllowEmptyStrings = false)]
public string BindAddress { get; set; } = "0.0.0.0";
}
public sealed class DockerEventFilterOptions
{
/// <summary>
/// Container event types to monitor.
/// </summary>
public IList<string> ContainerEvents { get; set; } = new List<string>
{
"start",
"stop",
"die",
"destroy",
"create"
};
/// <summary>
/// Image event types to monitor.
/// </summary>
public IList<string> ImageEvents { get; set; } = new List<string>
{
"pull",
"delete"
};
/// <summary>
/// Label filters for containers (key=value pairs).
/// </summary>
public IDictionary<string, string> LabelFilters { get; set; } = new Dictionary<string, string>();
}

View File

@@ -0,0 +1,213 @@
using System.Text.Json.Serialization;
namespace StellaOps.Zastava.Agent.Docker;
/// <summary>
/// Docker Engine API event from /events stream.
/// </summary>
public sealed class DockerEvent
{
/// <summary>
/// Event type: container, image, volume, network, daemon, plugin, node, service, secret, config.
/// </summary>
[JsonPropertyName("Type")]
public string Type { get; set; } = string.Empty;
/// <summary>
/// Event action: start, stop, die, create, destroy, pull, etc.
/// </summary>
[JsonPropertyName("Action")]
public string Action { get; set; } = string.Empty;
/// <summary>
/// Actor containing the event subject details.
/// </summary>
[JsonPropertyName("Actor")]
public DockerEventActor Actor { get; set; } = new();
/// <summary>
/// Unix timestamp of the event.
/// </summary>
[JsonPropertyName("time")]
public long Time { get; set; }
/// <summary>
/// Unix timestamp with nanoseconds.
/// </summary>
[JsonPropertyName("timeNano")]
public long TimeNano { get; set; }
/// <summary>
/// Event status (legacy field, same as Action).
/// </summary>
[JsonPropertyName("status")]
public string? Status { get; set; }
/// <summary>
/// Container ID (legacy field, same as Actor.ID for container events).
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
/// <summary>
/// Container image reference (legacy field).
/// </summary>
[JsonPropertyName("from")]
public string? From { get; set; }
}
/// <summary>
/// Actor details for Docker events.
/// </summary>
public sealed class DockerEventActor
{
/// <summary>
/// Resource ID (container ID, image ID, etc.).
/// </summary>
[JsonPropertyName("ID")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Attributes associated with the actor.
/// </summary>
[JsonPropertyName("Attributes")]
public Dictionary<string, string> Attributes { get; set; } = new();
}
/// <summary>
/// Docker container inspect response (subset of fields needed).
/// </summary>
public sealed class DockerContainerInspect
{
[JsonPropertyName("Id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("Created")]
public string Created { get; set; } = string.Empty;
[JsonPropertyName("Path")]
public string Path { get; set; } = string.Empty;
[JsonPropertyName("Args")]
public string[] Args { get; set; } = Array.Empty<string>();
[JsonPropertyName("State")]
public DockerContainerState State { get; set; } = new();
[JsonPropertyName("Image")]
public string Image { get; set; } = string.Empty;
[JsonPropertyName("Name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("Config")]
public DockerContainerConfig Config { get; set; } = new();
[JsonPropertyName("HostConfig")]
public DockerHostConfig? HostConfig { get; set; }
[JsonPropertyName("NetworkSettings")]
public DockerNetworkSettings? NetworkSettings { get; set; }
}
public sealed class DockerContainerState
{
[JsonPropertyName("Status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("Running")]
public bool Running { get; set; }
[JsonPropertyName("Pid")]
public int Pid { get; set; }
[JsonPropertyName("ExitCode")]
public int ExitCode { get; set; }
[JsonPropertyName("StartedAt")]
public string StartedAt { get; set; } = string.Empty;
[JsonPropertyName("FinishedAt")]
public string FinishedAt { get; set; } = string.Empty;
}
public sealed class DockerContainerConfig
{
[JsonPropertyName("Image")]
public string Image { get; set; } = string.Empty;
[JsonPropertyName("Labels")]
public Dictionary<string, string> Labels { get; set; } = new();
[JsonPropertyName("Env")]
public string[]? Env { get; set; }
[JsonPropertyName("Cmd")]
public string[]? Cmd { get; set; }
[JsonPropertyName("Entrypoint")]
public string[]? Entrypoint { get; set; }
[JsonPropertyName("WorkingDir")]
public string? WorkingDir { get; set; }
}
public sealed class DockerHostConfig
{
[JsonPropertyName("Binds")]
public string[]? Binds { get; set; }
[JsonPropertyName("NetworkMode")]
public string? NetworkMode { get; set; }
[JsonPropertyName("Privileged")]
public bool Privileged { get; set; }
[JsonPropertyName("PidMode")]
public string? PidMode { get; set; }
}
public sealed class DockerNetworkSettings
{
[JsonPropertyName("IPAddress")]
public string? IpAddress { get; set; }
[JsonPropertyName("Networks")]
public Dictionary<string, DockerNetworkEndpoint>? Networks { get; set; }
}
public sealed class DockerNetworkEndpoint
{
[JsonPropertyName("IPAddress")]
public string? IpAddress { get; set; }
[JsonPropertyName("Gateway")]
public string? Gateway { get; set; }
[JsonPropertyName("NetworkID")]
public string? NetworkId { get; set; }
}
/// <summary>
/// Docker image inspect response (subset of fields needed).
/// </summary>
public sealed class DockerImageInspect
{
[JsonPropertyName("Id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("RepoTags")]
public string[] RepoTags { get; set; } = Array.Empty<string>();
[JsonPropertyName("RepoDigests")]
public string[] RepoDigests { get; set; } = Array.Empty<string>();
[JsonPropertyName("Created")]
public string Created { get; set; } = string.Empty;
[JsonPropertyName("Size")]
public long Size { get; set; }
[JsonPropertyName("Config")]
public DockerContainerConfig? Config { get; set; }
}

View File

@@ -0,0 +1,296 @@
using System.IO.Pipes;
using System.Net.Http;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Agent.Configuration;
namespace StellaOps.Zastava.Agent.Docker;
/// <summary>
/// Docker Engine API client using unix socket (Linux) or named pipe (Windows).
/// </summary>
internal sealed class DockerSocketClient : IDockerSocketClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
private readonly ILogger<DockerSocketClient> _logger;
private readonly HttpClient _httpClient;
private bool _disposed;
public DockerSocketClient(
IOptionsMonitor<ZastavaAgentOptions> options,
ILogger<DockerSocketClient> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient = CreateHttpClient(options.CurrentValue);
}
private static HttpClient CreateHttpClient(ZastavaAgentOptions options)
{
var endpoint = options.DockerEndpoint;
var timeout = options.ConnectTimeout;
HttpMessageHandler handler;
if (endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase))
{
var socketPath = endpoint[7..]; // Remove "unix://"
handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
var endpoint = new UnixDomainSocketEndPoint(socketPath);
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
},
ConnectTimeout = timeout
};
}
else if (endpoint.StartsWith("npipe://", StringComparison.OrdinalIgnoreCase))
{
var pipeName = endpoint[8..].Replace("/", "\\"); // Remove "npipe://" and normalize path
if (pipeName.StartsWith(".\\pipe\\", StringComparison.OrdinalIgnoreCase))
{
pipeName = pipeName[7..]; // Remove ".\pipe\"
}
else if (pipeName.StartsWith("\\pipe\\", StringComparison.OrdinalIgnoreCase))
{
pipeName = pipeName[6..]; // Remove "\pipe\"
}
handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var pipe = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await pipe.ConnectAsync((int)timeout.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
return pipe;
},
ConnectTimeout = timeout
};
}
else if (endpoint.StartsWith("tcp://", StringComparison.OrdinalIgnoreCase) ||
endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
handler = new SocketsHttpHandler
{
ConnectTimeout = timeout
};
}
else
{
throw new ArgumentException($"Unsupported Docker endpoint scheme: {endpoint}", nameof(options));
}
return new HttpClient(handler)
{
BaseAddress = new Uri("http://localhost/"), // Placeholder for socket connections
Timeout = Timeout.InfiniteTimeSpan // We handle timeouts per-request
};
}
public async Task<bool> PingAsync(CancellationToken cancellationToken)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.CurrentValue.ConnectTimeout);
var response = await _httpClient.GetAsync("/_ping", cts.Token).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Docker ping failed");
return false;
}
}
public async Task<DockerVersionInfo> GetVersionAsync(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync("/version", cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<DockerVersionInfo>(content, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize Docker version response");
}
public async Task<DockerContainerInspect?> InspectContainerAsync(string containerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(containerId);
try
{
var response = await _httpClient.GetAsync($"/containers/{containerId}/json", cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<DockerContainerInspect>(content, JsonOptions);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
public async Task<DockerImageInspect?> InspectImageAsync(string imageRef, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(imageRef);
try
{
var encodedRef = Uri.EscapeDataString(imageRef);
var response = await _httpClient.GetAsync($"/images/{encodedRef}/json", cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<DockerImageInspect>(content, JsonOptions);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
public async IAsyncEnumerable<DockerEvent> StreamEventsAsync(
DockerEventFilterOptions? filters,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var queryBuilder = new StringBuilder("/events?");
if (filters != null)
{
var filterDict = new Dictionary<string, IList<string>>();
if (filters.ContainerEvents.Count > 0)
{
filterDict["type"] = new List<string> { "container" };
filterDict["event"] = filters.ContainerEvents.ToList();
}
if (filters.ImageEvents.Count > 0)
{
if (!filterDict.ContainsKey("type"))
{
filterDict["type"] = new List<string>();
}
((List<string>)filterDict["type"]).Add("image");
if (!filterDict.ContainsKey("event"))
{
filterDict["event"] = new List<string>();
}
foreach (var evt in filters.ImageEvents)
{
((List<string>)filterDict["event"]).Add(evt);
}
}
if (filters.LabelFilters.Count > 0)
{
filterDict["label"] = filters.LabelFilters.Select(kv => $"{kv.Key}={kv.Value}").ToList();
}
if (filterDict.Count > 0)
{
var filterJson = JsonSerializer.Serialize(filterDict, JsonOptions);
queryBuilder.Append("filters=").Append(Uri.EscapeDataString(filterJson));
}
}
var url = queryBuilder.ToString().TrimEnd('?');
_logger.LogDebug("Starting Docker event stream: {Url}", url);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line == null)
{
// Stream ended
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
DockerEvent? evt;
try
{
evt = JsonSerializer.Deserialize<DockerEvent>(line, JsonOptions);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse Docker event: {Line}", line);
continue;
}
if (evt != null)
{
yield return evt;
}
}
}
public async Task<IReadOnlyList<DockerContainerSummary>> ListContainersAsync(
bool all = false,
CancellationToken cancellationToken = default)
{
var url = all ? "/containers/json?all=true" : "/containers/json";
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<List<DockerContainerSummary>>(content, JsonOptions)
?? new List<DockerContainerSummary>();
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_httpClient.Dispose();
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,76 @@
using StellaOps.Zastava.Agent.Configuration;
namespace StellaOps.Zastava.Agent.Docker;
/// <summary>
/// Client interface for Docker Engine API via unix socket or named pipe.
/// </summary>
public interface IDockerSocketClient : IAsyncDisposable
{
/// <summary>
/// Check if Docker daemon is reachable.
/// </summary>
Task<bool> PingAsync(CancellationToken cancellationToken);
/// <summary>
/// Get Docker version information.
/// </summary>
Task<DockerVersionInfo> GetVersionAsync(CancellationToken cancellationToken);
/// <summary>
/// Inspect a container by ID.
/// </summary>
Task<DockerContainerInspect?> InspectContainerAsync(string containerId, CancellationToken cancellationToken);
/// <summary>
/// Inspect an image by ID or reference.
/// </summary>
Task<DockerImageInspect?> InspectImageAsync(string imageRef, CancellationToken cancellationToken);
/// <summary>
/// Stream container/image events from Docker daemon.
/// </summary>
IAsyncEnumerable<DockerEvent> StreamEventsAsync(
DockerEventFilterOptions? filters,
CancellationToken cancellationToken);
/// <summary>
/// List running containers.
/// </summary>
Task<IReadOnlyList<DockerContainerSummary>> ListContainersAsync(
bool all = false,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Docker version response.
/// </summary>
public sealed class DockerVersionInfo
{
public string Version { get; set; } = string.Empty;
public string ApiVersion { get; set; } = string.Empty;
public string MinAPIVersion { get; set; } = string.Empty;
public string GitCommit { get; set; } = string.Empty;
public string GoVersion { get; set; } = string.Empty;
public string Os { get; set; } = string.Empty;
public string Arch { get; set; } = string.Empty;
public string KernelVersion { get; set; } = string.Empty;
public bool Experimental { get; set; }
public string BuildTime { get; set; } = string.Empty;
}
/// <summary>
/// Container list response item.
/// </summary>
public sealed class DockerContainerSummary
{
public string Id { get; set; } = string.Empty;
public string[] Names { get; set; } = Array.Empty<string>();
public string Image { get; set; } = string.Empty;
public string ImageID { get; set; } = string.Empty;
public string Command { get; set; } = string.Empty;
public long Created { get; set; }
public string State { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public Dictionary<string, string> Labels { get; set; } = new();
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using StellaOps.Zastava.Agent.Worker;
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateBootstrapLogger();
try
{
Log.Information("Starting Zastava Agent...");
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSerilog((services, loggerConfiguration) =>
{
loggerConfiguration
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");
});
builder.Services.AddZastavaAgent(builder.Configuration);
var host = builder.Build();
await host.RunAsync();
}
catch (Exception ex)
{
Log.Fatal(ex, "Zastava Agent terminated unexpectedly");
return 1;
}
finally
{
await Log.CloseAndFlushAsync();
}
return 0;

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Zastava.Agent</RootNamespace>
<AssemblyName>StellaOps.Zastava.Agent</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Secrets;
using StellaOps.Zastava.Agent.Backend;
using StellaOps.Zastava.Agent.Configuration;
using StellaOps.Zastava.Agent.Docker;
using StellaOps.Zastava.Agent.Worker;
using StellaOps.Zastava.Core.Configuration;
namespace Microsoft.Extensions.DependencyInjection;
public static class AgentServiceCollectionExtensions
{
public static IServiceCollection AddZastavaAgent(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Add shared runtime core services
services.AddZastavaRuntimeCore(configuration, componentName: "agent");
services.AddStellaOpsCrypto();
// Configure agent-specific options
services.AddOptions<ZastavaAgentOptions>()
.Bind(configuration.GetSection(ZastavaAgentOptions.SectionName))
.ValidateDataAnnotations()
.PostConfigure(options =>
{
if (options.Backoff.Initial <= TimeSpan.Zero)
{
options.Backoff.Initial = TimeSpan.FromSeconds(1);
}
if (options.Backoff.Max < options.Backoff.Initial)
{
options.Backoff.Max = options.Backoff.Initial;
}
if (!options.Backend.AllowInsecureHttp &&
!string.Equals(options.Backend.BaseAddress.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Agent backend baseAddress must use HTTPS unless allowInsecureHttp is explicitly enabled.");
}
if (!options.Backend.EventsPath.StartsWith("/", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Agent backend eventsPath must be absolute (start with '/').");
}
})
.ValidateOnStart();
// Register time provider
services.TryAddSingleton(TimeProvider.System);
// Register runtime options post-configure for component name
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPostConfigureOptions<ZastavaRuntimeOptions>, AgentRuntimeOptionsPostConfigure>());
// Register Docker client
services.TryAddSingleton<IDockerSocketClient, DockerSocketClient>();
// Register runtime event buffer
services.TryAddSingleton<IRuntimeEventBuffer, RuntimeEventBuffer>();
// Register backend HTTP client
services.AddHttpClient<IRuntimeEventsClient, RuntimeEventsClient>()
.ConfigureHttpClient((provider, client) =>
{
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<ZastavaAgentOptions>>();
var backend = optionsMonitor.CurrentValue.Backend;
client.BaseAddress = backend.BaseAddress;
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(backend.RequestTimeoutSeconds, 1, 120));
});
// Surface environment setup
services.AddSurfaceEnvironment(options =>
{
options.ComponentName = "Zastava.Agent";
options.AddPrefix("ZASTAVA_AGENT");
options.AddPrefix("ZASTAVA");
options.TenantResolver = sp => sp.GetRequiredService<IOptions<ZastavaRuntimeOptions>>().Value.Tenant;
});
services.AddSurfaceSecrets(options =>
{
options.ComponentName = "Zastava.Agent";
});
// Register hosted services
services.AddHostedService<HealthCheckHostedService>();
services.AddHostedService<DockerEventHostedService>();
services.AddHostedService<RuntimeEventDispatchService>();
return services;
}
}
internal sealed class AgentRuntimeOptionsPostConfigure : IPostConfigureOptions<ZastavaRuntimeOptions>
{
public void PostConfigure(string? name, ZastavaRuntimeOptions options)
{
if (string.IsNullOrWhiteSpace(options.Component))
{
options.Component = "agent";
}
}
}

View File

@@ -0,0 +1,353 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Agent.Configuration;
using StellaOps.Zastava.Agent.Docker;
using StellaOps.Zastava.Core.Contracts;
using StellaOps.Zastava.Core.Configuration;
namespace StellaOps.Zastava.Agent.Worker;
/// <summary>
/// Background service that monitors Docker events and converts them to Zastava runtime events.
/// </summary>
internal sealed class DockerEventHostedService : BackgroundService
{
private readonly IDockerSocketClient _dockerClient;
private readonly IRuntimeEventBuffer _eventBuffer;
private readonly IOptionsMonitor<ZastavaAgentOptions> _agentOptions;
private readonly IOptionsMonitor<ZastavaRuntimeOptions> _runtimeOptions;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DockerEventHostedService> _logger;
private readonly Random _jitterRandom = new();
public DockerEventHostedService(
IDockerSocketClient dockerClient,
IRuntimeEventBuffer eventBuffer,
IOptionsMonitor<ZastavaAgentOptions> agentOptions,
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
TimeProvider timeProvider,
ILogger<DockerEventHostedService> logger)
{
_dockerClient = dockerClient ?? throw new ArgumentNullException(nameof(dockerClient));
_eventBuffer = eventBuffer ?? throw new ArgumentNullException(nameof(eventBuffer));
_agentOptions = agentOptions ?? throw new ArgumentNullException(nameof(agentOptions));
_runtimeOptions = runtimeOptions ?? throw new ArgumentNullException(nameof(runtimeOptions));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var options = _agentOptions.CurrentValue;
var backoffOptions = options.Backoff;
var failureCount = 0;
_logger.LogInformation("Docker event watcher starting for endpoint {Endpoint}", options.DockerEndpoint);
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Wait for Docker to become available
if (!await WaitForDockerAsync(stoppingToken).ConfigureAwait(false))
{
continue;
}
// Get Docker version info for logging
var version = await _dockerClient.GetVersionAsync(stoppingToken).ConfigureAwait(false);
_logger.LogInformation(
"Connected to Docker {Version} (API {ApiVersion}) on {Os}/{Arch}",
version.Version,
version.ApiVersion,
version.Os,
version.Arch);
// Process initial container state
await ProcessExistingContainersAsync(stoppingToken).ConfigureAwait(false);
// Stream events
failureCount = 0;
await foreach (var dockerEvent in _dockerClient.StreamEventsAsync(options.EventFilters, stoppingToken))
{
try
{
await ProcessDockerEventAsync(dockerEvent, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process Docker event: {Type}/{Action}", dockerEvent.Type, dockerEvent.Action);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Docker event watcher stopping due to cancellation");
return;
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
failureCount++;
var delay = ComputeBackoffDelay(backoffOptions, failureCount);
_logger.LogWarning(
ex,
"Docker event stream error (attempt {Attempt}); retrying after {Delay}",
failureCount,
delay);
try
{
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
}
}
}
private async Task<bool> WaitForDockerAsync(CancellationToken cancellationToken)
{
var options = _agentOptions.CurrentValue;
var backoffOptions = options.Backoff;
var attempt = 0;
while (!cancellationToken.IsCancellationRequested)
{
if (await _dockerClient.PingAsync(cancellationToken).ConfigureAwait(false))
{
return true;
}
attempt++;
var delay = ComputeBackoffDelay(backoffOptions, attempt);
_logger.LogDebug("Docker daemon not ready (attempt {Attempt}); retrying after {Delay}", attempt, delay);
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
}
return false;
}
private async Task ProcessExistingContainersAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Scanning existing containers...");
var containers = await _dockerClient.ListContainersAsync(all: false, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Found {Count} running containers", containers.Count);
foreach (var container in containers)
{
try
{
var inspect = await _dockerClient.InspectContainerAsync(container.Id, cancellationToken).ConfigureAwait(false);
if (inspect == null)
{
continue;
}
var envelope = CreateRuntimeEventEnvelope(inspect, RuntimeEventKind.ContainerStart);
await _eventBuffer.WriteBatchAsync(new[] { envelope }, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Emitted ContainerStart event for existing container {ContainerId}", container.Id[..12]);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process existing container {ContainerId}", container.Id[..12]);
}
}
}
private async Task ProcessDockerEventAsync(DockerEvent dockerEvent, CancellationToken cancellationToken)
{
if (!string.Equals(dockerEvent.Type, "container", StringComparison.OrdinalIgnoreCase))
{
return; // Only process container events for now
}
var containerId = dockerEvent.Actor.Id;
if (string.IsNullOrWhiteSpace(containerId))
{
containerId = dockerEvent.Id;
}
if (string.IsNullOrWhiteSpace(containerId))
{
_logger.LogDebug("Skipping Docker event without container ID: {Action}", dockerEvent.Action);
return;
}
var eventKind = MapDockerActionToEventKind(dockerEvent.Action);
if (eventKind == null)
{
_logger.LogDebug("Ignoring Docker action: {Action}", dockerEvent.Action);
return;
}
_logger.LogDebug(
"Processing Docker event: container={ContainerId}, action={Action}, kind={Kind}",
containerId[..Math.Min(12, containerId.Length)],
dockerEvent.Action,
eventKind);
// For start events, inspect the container for full details
DockerContainerInspect? inspect = null;
if (eventKind == RuntimeEventKind.ContainerStart)
{
inspect = await _dockerClient.InspectContainerAsync(containerId, cancellationToken).ConfigureAwait(false);
if (inspect == null)
{
_logger.LogWarning("Container {ContainerId} not found for inspection", containerId[..12]);
return;
}
}
var envelope = inspect != null
? CreateRuntimeEventEnvelope(inspect, eventKind.Value)
: CreateRuntimeEventEnvelopeFromEvent(dockerEvent, eventKind.Value);
await _eventBuffer.WriteBatchAsync(new[] { envelope }, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Emitted {Kind} event for container {ContainerId} ({Image})",
eventKind,
containerId[..Math.Min(12, containerId.Length)],
dockerEvent.Actor.Attributes.GetValueOrDefault("image") ?? dockerEvent.From ?? "unknown");
}
private static RuntimeEventKind? MapDockerActionToEventKind(string action)
{
return action.ToLowerInvariant() switch
{
"start" => RuntimeEventKind.ContainerStart,
"stop" => RuntimeEventKind.ContainerStop,
"die" => RuntimeEventKind.ContainerStop,
"destroy" => RuntimeEventKind.ContainerStop,
"create" => null, // Wait for start
"exec_start" => RuntimeEventKind.ContainerStart, // exec treated as start-like event
"exec_create" => null, // Wait for exec_start
_ => null
};
}
private RuntimeEventEnvelope CreateRuntimeEventEnvelope(DockerContainerInspect container, RuntimeEventKind kind)
{
var runtime = _runtimeOptions.CurrentValue;
var agent = _agentOptions.CurrentValue;
var now = _timeProvider.GetUtcNow();
var imageRef = container.Config.Image;
var entrypoint = container.Config.Entrypoint ?? Array.Empty<string>();
var cmd = container.Config.Cmd ?? Array.Empty<string>();
var entrypointList = entrypoint.Concat(cmd).Take(agent.MaxTrackedLibraries).ToArray();
var runtimeEvent = new RuntimeEvent
{
EventId = $"docker-{container.Id[..12]}-{kind.ToString().ToLowerInvariant()}-{now.ToUnixTimeMilliseconds()}",
Tenant = runtime.Tenant,
Node = agent.NodeName,
Kind = kind,
When = now,
Workload = new RuntimeWorkload
{
Platform = "docker",
Namespace = "default", // Docker doesn't have namespaces
Pod = container.Name.TrimStart('/'),
Container = container.Name.TrimStart('/'),
ContainerId = container.Id,
ImageRef = imageRef
},
Runtime = new RuntimeEngine
{
Engine = "docker",
Version = null // Would need version from /version call
},
Process = kind == RuntimeEventKind.ContainerStart
? new RuntimeProcess
{
Pid = container.State.Pid,
Entrypoint = entrypointList
}
: null,
Posture = new RuntimePosture
{
ImageSigned = false, // Would need cosign verification
SbomReferrer = null
}
};
return new RuntimeEventEnvelope
{
SchemaVersion = "1.0",
Event = runtimeEvent
};
}
private RuntimeEventEnvelope CreateRuntimeEventEnvelopeFromEvent(DockerEvent dockerEvent, RuntimeEventKind kind)
{
var runtime = _runtimeOptions.CurrentValue;
var agent = _agentOptions.CurrentValue;
var now = _timeProvider.GetUtcNow();
var containerId = dockerEvent.Actor.Id ?? dockerEvent.Id ?? "unknown";
var imageRef = dockerEvent.Actor.Attributes.GetValueOrDefault("image") ?? dockerEvent.From ?? "unknown";
var containerName = dockerEvent.Actor.Attributes.GetValueOrDefault("name") ?? containerId[..Math.Min(12, containerId.Length)];
var runtimeEvent = new RuntimeEvent
{
EventId = $"docker-{containerId[..Math.Min(12, containerId.Length)]}-{kind.ToString().ToLowerInvariant()}-{now.ToUnixTimeMilliseconds()}",
Tenant = runtime.Tenant,
Node = agent.NodeName,
Kind = kind,
When = now,
Workload = new RuntimeWorkload
{
Platform = "docker",
Namespace = "default",
Pod = containerName,
Container = containerName,
ContainerId = containerId,
ImageRef = imageRef
},
Runtime = new RuntimeEngine
{
Engine = "docker",
Version = null
}
};
return new RuntimeEventEnvelope
{
SchemaVersion = "1.0",
Event = runtimeEvent
};
}
private TimeSpan ComputeBackoffDelay(AgentBackoffOptions options, int failureCount)
{
var baseDelay = options.Initial.TotalMilliseconds * Math.Pow(2, failureCount - 1);
var cappedDelay = Math.Min(baseDelay, options.Max.TotalMilliseconds);
if (options.JitterRatio > 0)
{
var jitter = cappedDelay * options.JitterRatio * (_jitterRandom.NextDouble() * 2 - 1);
cappedDelay = Math.Max(options.Initial.TotalMilliseconds, cappedDelay + jitter);
}
return TimeSpan.FromMilliseconds(cappedDelay);
}
}

View File

@@ -0,0 +1,269 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Agent.Configuration;
using StellaOps.Zastava.Agent.Docker;
namespace StellaOps.Zastava.Agent.Worker;
/// <summary>
/// Lightweight HTTP server providing /healthz and /readyz endpoints for non-Kubernetes monitoring.
/// </summary>
internal sealed class HealthCheckHostedService : BackgroundService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly IDockerSocketClient _dockerClient;
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
private readonly ILogger<HealthCheckHostedService> _logger;
private HttpListener? _listener;
public HealthCheckHostedService(
IDockerSocketClient dockerClient,
IOptionsMonitor<ZastavaAgentOptions> options,
ILogger<HealthCheckHostedService> logger)
{
_dockerClient = dockerClient ?? throw new ArgumentNullException(nameof(dockerClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var healthCheckOptions = _options.CurrentValue.HealthCheck;
if (!healthCheckOptions.Enabled)
{
_logger.LogInformation("Health check server disabled");
return;
}
var prefix = $"http://{healthCheckOptions.BindAddress}:{healthCheckOptions.Port}/";
try
{
_listener = new HttpListener();
_listener.Prefixes.Add(prefix);
_listener.Start();
_logger.LogInformation("Health check server started on {Prefix}", prefix);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync().WaitAsync(stoppingToken).ConfigureAwait(false);
_ = ProcessRequestAsync(context, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (HttpListenerException ex) when (stoppingToken.IsCancellationRequested)
{
_logger.LogDebug(ex, "HttpListener stopped");
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error accepting health check request");
}
}
}
catch (HttpListenerException ex)
{
_logger.LogError(ex, "Failed to start health check server on {Prefix}", prefix);
}
finally
{
_listener?.Stop();
_listener?.Close();
}
}
private async Task ProcessRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var request = context.Request;
var response = context.Response;
try
{
var path = request.Url?.AbsolutePath ?? "/";
(int statusCode, object body) = path.ToLowerInvariant() switch
{
"/healthz" => await HandleHealthzAsync(cancellationToken).ConfigureAwait(false),
"/readyz" => await HandleReadyzAsync(cancellationToken).ConfigureAwait(false),
"/livez" => (200, new { status = "ok" }),
"/" => (200, new { service = "zastava-agent", endpoints = new[] { "/healthz", "/readyz", "/livez" } }),
_ => (404, new { error = "not found" })
};
response.StatusCode = statusCode;
response.ContentType = "application/json";
var json = JsonSerializer.Serialize(body, JsonOptions);
var buffer = Encoding.UTF8.GetBytes(json);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error processing health check request");
response.StatusCode = 500;
}
finally
{
response.Close();
}
}
private async Task<(int statusCode, object body)> HandleHealthzAsync(CancellationToken cancellationToken)
{
var checks = new List<HealthCheckResult>();
var overallHealthy = true;
// Check Docker connectivity
try
{
var dockerAvailable = await _dockerClient.PingAsync(cancellationToken).ConfigureAwait(false);
checks.Add(new HealthCheckResult
{
Name = "docker",
Status = dockerAvailable ? "healthy" : "unhealthy",
Message = dockerAvailable ? "Docker daemon reachable" : "Docker daemon unreachable"
});
if (!dockerAvailable)
{
overallHealthy = false;
}
}
catch (Exception ex)
{
checks.Add(new HealthCheckResult
{
Name = "docker",
Status = "unhealthy",
Message = $"Docker check failed: {ex.Message}"
});
overallHealthy = false;
}
// Check event buffer directory
var bufferPath = _options.CurrentValue.EventBufferPath;
try
{
var bufferExists = Directory.Exists(bufferPath);
var bufferWritable = bufferExists && IsDirectoryWritable(bufferPath);
checks.Add(new HealthCheckResult
{
Name = "event_buffer",
Status = bufferWritable ? "healthy" : "degraded",
Message = bufferWritable ? "Event buffer writable" : "Event buffer not writable"
});
}
catch (Exception ex)
{
checks.Add(new HealthCheckResult
{
Name = "event_buffer",
Status = "degraded",
Message = $"Buffer check failed: {ex.Message}"
});
}
var response = new HealthCheckResponse
{
Status = overallHealthy ? "healthy" : "unhealthy",
Checks = checks,
Timestamp = DateTimeOffset.UtcNow
};
return (overallHealthy ? 200 : 503, response);
}
private async Task<(int statusCode, object body)> HandleReadyzAsync(CancellationToken cancellationToken)
{
// Ready check: Docker must be reachable
try
{
var dockerAvailable = await _dockerClient.PingAsync(cancellationToken).ConfigureAwait(false);
if (dockerAvailable)
{
return (200, new ReadyCheckResponse
{
Status = "ready",
Message = "Agent ready to process container events",
Timestamp = DateTimeOffset.UtcNow
});
}
return (503, new ReadyCheckResponse
{
Status = "not_ready",
Message = "Docker daemon not reachable",
Timestamp = DateTimeOffset.UtcNow
});
}
catch (Exception ex)
{
return (503, new ReadyCheckResponse
{
Status = "not_ready",
Message = $"Ready check failed: {ex.Message}",
Timestamp = DateTimeOffset.UtcNow
});
}
}
private static bool IsDirectoryWritable(string path)
{
try
{
var testFile = Path.Combine(path, $".healthcheck-{Guid.NewGuid():N}");
File.WriteAllText(testFile, "test");
File.Delete(testFile);
return true;
}
catch
{
return false;
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_listener?.Stop();
await base.StopAsync(cancellationToken).ConfigureAwait(false);
}
}
internal sealed class HealthCheckResponse
{
public required string Status { get; init; }
public required IReadOnlyList<HealthCheckResult> Checks { get; init; }
public DateTimeOffset Timestamp { get; init; }
}
internal sealed class HealthCheckResult
{
public required string Name { get; init; }
public required string Status { get; init; }
public string? Message { get; init; }
}
internal sealed class ReadyCheckResponse
{
public required string Status { get; init; }
public string? Message { get; init; }
public DateTimeOffset Timestamp { get; init; }
}

View File

@@ -0,0 +1,300 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Agent.Configuration;
using StellaOps.Zastava.Core.Contracts;
using StellaOps.Zastava.Core.Serialization;
namespace StellaOps.Zastava.Agent.Worker;
internal interface IRuntimeEventBuffer
{
ValueTask WriteBatchAsync(IReadOnlyList<RuntimeEventEnvelope> envelopes, CancellationToken cancellationToken);
IAsyncEnumerable<RuntimeEventBufferItem> ReadAllAsync(CancellationToken cancellationToken);
}
internal sealed record RuntimeEventBufferItem(
RuntimeEventEnvelope Envelope,
Func<ValueTask> CompleteAsync,
Func<CancellationToken, ValueTask> RequeueAsync);
internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
{
private const string FileExtension = ".json";
private readonly Channel<string> _channel;
private readonly ConcurrentDictionary<string, byte> _inFlight = new(StringComparer.OrdinalIgnoreCase);
private readonly object _capacityLock = new();
private readonly string _spoolPath;
private readonly ILogger<RuntimeEventBuffer> _logger;
private readonly TimeProvider _timeProvider;
private readonly long _maxDiskBytes;
private readonly int _capacity;
private long _currentBytes;
public RuntimeEventBuffer(
IOptions<ZastavaAgentOptions> agentOptions,
TimeProvider timeProvider,
ILogger<RuntimeEventBuffer> logger)
{
ArgumentNullException.ThrowIfNull(agentOptions);
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var options = agentOptions.Value ?? throw new ArgumentNullException(nameof(agentOptions));
_capacity = Math.Clamp(options.MaxInMemoryBuffer, 16, 65536);
_spoolPath = EnsureSpoolDirectory(options.EventBufferPath);
_maxDiskBytes = Math.Clamp(options.MaxDiskBufferBytes, 1_048_576L, 1_073_741_824L); // 1 MiB 1 GiB
var channelOptions = new BoundedChannelOptions(_capacity)
{
AllowSynchronousContinuations = false,
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false
};
_channel = Channel.CreateBounded<string>(channelOptions);
var existingFiles = Directory.EnumerateFiles(_spoolPath, $"*{FileExtension}", SearchOption.TopDirectoryOnly)
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
foreach (var path in existingFiles)
{
var size = TryGetLength(path);
if (size > 0)
{
Interlocked.Add(ref _currentBytes, size);
}
// Enqueue existing events for replay
if (!_channel.Writer.TryWrite(path))
{
_ = _channel.Writer.WriteAsync(path);
}
}
if (existingFiles.Length > 0)
{
_logger.LogInformation(
"Runtime event buffer restored {Count} pending events ({Bytes} bytes) from disk spool.",
existingFiles.Length,
Interlocked.Read(ref _currentBytes));
}
}
public async ValueTask WriteBatchAsync(IReadOnlyList<RuntimeEventEnvelope> envelopes, CancellationToken cancellationToken)
{
if (envelopes is null || envelopes.Count == 0)
{
return;
}
foreach (var envelope in envelopes)
{
cancellationToken.ThrowIfCancellationRequested();
var payload = ZastavaCanonicalJsonSerializer.SerializeToUtf8Bytes(envelope);
var filePath = await PersistAsync(payload, cancellationToken).ConfigureAwait(false);
await _channel.Writer.WriteAsync(filePath, cancellationToken).ConfigureAwait(false);
}
if (envelopes.Count > _capacity / 2)
{
_logger.LogDebug("Buffered {Count} runtime events; channel capacity {Capacity}.", envelopes.Count, _capacity);
}
}
public async IAsyncEnumerable<RuntimeEventBufferItem> ReadAllAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
while (await _channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (_channel.Reader.TryRead(out var filePath))
{
cancellationToken.ThrowIfCancellationRequested();
if (!File.Exists(filePath))
{
RemoveMetricsForMissingFile(filePath);
continue;
}
RuntimeEventEnvelope? envelope = null;
try
{
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
envelope = ZastavaCanonicalJsonSerializer.Deserialize<RuntimeEventEnvelope>(json);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read runtime event payload from {Path}; dropping.", filePath);
await DeleteFileSilentlyAsync(filePath).ConfigureAwait(false);
continue;
}
var currentPath = filePath;
_inFlight[currentPath] = 0;
yield return new RuntimeEventBufferItem(
envelope,
CompleteAsync(currentPath),
RequeueAsync(currentPath));
}
}
}
private Func<ValueTask> CompleteAsync(string filePath)
=> async () =>
{
try
{
await DeleteFileSilentlyAsync(filePath).ConfigureAwait(false);
}
finally
{
_inFlight.TryRemove(filePath, out _);
}
};
private Func<CancellationToken, ValueTask> RequeueAsync(string filePath)
=> async cancellationToken =>
{
_inFlight.TryRemove(filePath, out _);
if (!File.Exists(filePath))
{
RemoveMetricsForMissingFile(filePath);
return;
}
await _channel.Writer.WriteAsync(filePath, cancellationToken).ConfigureAwait(false);
};
private async Task<string> PersistAsync(byte[] payload, CancellationToken cancellationToken)
{
var timestamp = _timeProvider.GetUtcNow().UtcTicks;
var fileName = $"{timestamp:D20}-{Guid.NewGuid():N}{FileExtension}";
var filePath = Path.Combine(_spoolPath, fileName);
Directory.CreateDirectory(_spoolPath);
await File.WriteAllBytesAsync(filePath, payload, cancellationToken).ConfigureAwait(false);
Interlocked.Add(ref _currentBytes, payload.Length);
EnforceCapacity();
return filePath;
}
private void EnforceCapacity()
{
if (Volatile.Read(ref _currentBytes) <= _maxDiskBytes)
{
return;
}
lock (_capacityLock)
{
if (_currentBytes <= _maxDiskBytes)
{
return;
}
var candidates = Directory.EnumerateFiles(_spoolPath, $"*{FileExtension}", SearchOption.TopDirectoryOnly)
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
foreach (var file in candidates)
{
if (_currentBytes <= _maxDiskBytes)
{
break;
}
if (_inFlight.ContainsKey(file))
{
continue;
}
var length = TryGetLength(file);
try
{
File.Delete(file);
if (length > 0)
{
Interlocked.Add(ref _currentBytes, -length);
}
_logger.LogWarning(
"Dropped runtime event {FileName} to enforce disk buffer capacity (limit {MaxBytes} bytes).",
Path.GetFileName(file),
_maxDiskBytes);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to purge runtime event buffer file {FileName}.", Path.GetFileName(file));
}
}
}
}
private Task DeleteFileSilentlyAsync(string filePath)
{
if (!File.Exists(filePath))
{
return Task.CompletedTask;
}
var length = TryGetLength(filePath);
try
{
File.Delete(filePath);
if (length > 0)
{
Interlocked.Add(ref _currentBytes, -length);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete runtime event buffer file {FileName}.", Path.GetFileName(filePath));
}
return Task.CompletedTask;
}
private void RemoveMetricsForMissingFile(string filePath)
{
var length = TryGetLength(filePath);
if (length > 0)
{
Interlocked.Add(ref _currentBytes, -length);
}
}
private static string EnsureSpoolDirectory(string? value)
{
var defaultPath = OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "zastava-agent", "runtime-events")
: Path.Combine("/var/lib/zastava-agent", "runtime-events");
var path = string.IsNullOrWhiteSpace(value) ? defaultPath : value!;
Directory.CreateDirectory(path);
return path;
}
private static long TryGetLength(string path)
{
try
{
var info = new FileInfo(path);
return info.Exists ? info.Length : 0;
}
catch
{
return 0;
}
}
}

View File

@@ -0,0 +1,208 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Agent.Backend;
using StellaOps.Zastava.Agent.Configuration;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Zastava.Agent.Worker;
/// <summary>
/// Background service that drains the runtime event buffer and dispatches events to the backend.
/// </summary>
internal sealed class RuntimeEventDispatchService : BackgroundService
{
private readonly IRuntimeEventBuffer _eventBuffer;
private readonly IRuntimeEventsClient _eventsClient;
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
private readonly ILogger<RuntimeEventDispatchService> _logger;
private readonly Random _jitterRandom = new();
public RuntimeEventDispatchService(
IRuntimeEventBuffer eventBuffer,
IRuntimeEventsClient eventsClient,
IOptionsMonitor<ZastavaAgentOptions> options,
ILogger<RuntimeEventDispatchService> logger)
{
_eventBuffer = eventBuffer ?? throw new ArgumentNullException(nameof(eventBuffer));
_eventsClient = eventsClient ?? throw new ArgumentNullException(nameof(eventsClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var options = _options.CurrentValue;
var batchSize = options.PublishBatchSize;
var flushInterval = TimeSpan.FromSeconds(options.PublishFlushIntervalSeconds);
var backoffOptions = options.Backoff;
_logger.LogInformation(
"Runtime event dispatcher starting (batchSize={BatchSize}, flushInterval={FlushInterval})",
batchSize,
flushInterval);
var batch = new List<RuntimeEventBufferItem>(batchSize);
var lastFlush = DateTimeOffset.UtcNow;
var failureCount = 0;
try
{
await foreach (var item in _eventBuffer.ReadAllAsync(stoppingToken))
{
batch.Add(item);
var shouldFlush = batch.Count >= batchSize ||
(batch.Count > 0 && DateTimeOffset.UtcNow - lastFlush >= flushInterval);
if (shouldFlush)
{
var success = await FlushBatchAsync(batch, backoffOptions, failureCount, stoppingToken).ConfigureAwait(false);
if (success)
{
failureCount = 0;
}
else
{
failureCount++;
}
batch.Clear();
lastFlush = DateTimeOffset.UtcNow;
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Final flush on shutdown
if (batch.Count > 0)
{
_logger.LogInformation("Flushing {Count} remaining events on shutdown...", batch.Count);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await FlushBatchAsync(batch, backoffOptions, failureCount: 0, cts.Token).ConfigureAwait(false);
}
}
}
private async Task<bool> FlushBatchAsync(
List<RuntimeEventBufferItem> batch,
AgentBackoffOptions backoffOptions,
int failureCount,
CancellationToken cancellationToken)
{
if (batch.Count == 0)
{
return true;
}
var envelopes = batch.Select(static item => item.Envelope).ToArray();
_logger.LogDebug("Dispatching {Count} runtime events to backend...", envelopes.Length);
var result = await _eventsClient.SubmitAsync(envelopes, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
_logger.LogInformation(
"Dispatched {Accepted} runtime events ({Duplicates} duplicates)",
result.Accepted,
result.Duplicates);
// Complete all items (remove from disk buffer)
foreach (var item in batch)
{
try
{
await item.CompleteAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to complete buffer item for event {EventId}", item.Envelope.Event.EventId);
}
}
return true;
}
if (result.RateLimited && result.RetryAfter.HasValue)
{
_logger.LogWarning(
"Backend rate limited; requeuing {Count} events, retry after {RetryAfter}",
batch.Count,
result.RetryAfter);
// Requeue all items for retry
foreach (var item in batch)
{
try
{
await item.RequeueAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to requeue buffer item for event {EventId}", item.Envelope.Event.EventId);
}
}
// Wait for rate limit to clear
try
{
await Task.Delay(result.RetryAfter.Value, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown requested
}
return false;
}
// Non-rate-limit failure - apply exponential backoff
_logger.LogWarning(
"Failed to dispatch runtime events (error: {Error}); requeuing {Count} events",
result.ErrorMessage,
batch.Count);
// Requeue all items for retry
foreach (var item in batch)
{
try
{
await item.RequeueAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to requeue buffer item for event {EventId}", item.Envelope.Event.EventId);
}
}
// Apply backoff delay
var delay = ComputeBackoffDelay(backoffOptions, failureCount + 1);
_logger.LogDebug("Applying backoff delay of {Delay} before next dispatch attempt", delay);
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown requested
}
return false;
}
private TimeSpan ComputeBackoffDelay(AgentBackoffOptions options, int failureCount)
{
var baseDelay = options.Initial.TotalMilliseconds * Math.Pow(2, failureCount - 1);
var cappedDelay = Math.Min(baseDelay, options.Max.TotalMilliseconds);
if (options.JitterRatio > 0)
{
var jitter = cappedDelay * options.JitterRatio * (_jitterRandom.NextDouble() * 2 - 1);
cappedDelay = Math.Max(options.Initial.TotalMilliseconds, cappedDelay + jitter);
}
return TimeSpan.FromMilliseconds(cappedDelay);
}
}

View File

@@ -0,0 +1,58 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
]
},
"zastava": {
"runtime": {
"tenant": "default"
},
"agent": {
"nodeName": "",
"dockerEndpoint": "unix:///var/run/docker.sock",
"connectTimeout": "00:00:05",
"maxInMemoryBuffer": 2048,
"publishBatchSize": 32,
"publishFlushIntervalSeconds": 2,
"eventBufferPath": "/var/lib/zastava-agent/runtime-events",
"maxDiskBufferBytes": 67108864,
"backoff": {
"initial": "00:00:01",
"max": "00:00:30",
"jitterRatio": 0.2
},
"backend": {
"baseAddress": "https://scanner.internal",
"eventsPath": "/api/v1/runtime/events",
"requestTimeoutSeconds": 5,
"allowInsecureHttp": false
},
"procRootPath": "/proc",
"maxTrackedLibraries": 256,
"maxLibraryBytes": 33554432,
"healthCheck": {
"enabled": true,
"port": 8080,
"bindAddress": "0.0.0.0"
},
"eventFilters": {
"containerEvents": ["start", "stop", "die", "destroy", "create"],
"imageEvents": ["pull", "delete"],
"labelFilters": {}
}
}
}
}

View File

@@ -0,0 +1,396 @@
using System.IO.Pipes;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Windows container runtime client using Docker Engine API over named pipe.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DockerWindowsRuntimeClient : IWindowsContainerRuntimeClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private const string DefaultPipeName = "docker_engine";
private readonly string _pipeName;
private readonly TimeSpan _connectTimeout;
private readonly HttpClient _httpClient;
private readonly ILogger<DockerWindowsRuntimeClient> _logger;
private bool _disposed;
public DockerWindowsRuntimeClient(
ILogger<DockerWindowsRuntimeClient> logger,
string? pipeName = null,
TimeSpan? connectTimeout = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_pipeName = pipeName ?? DefaultPipeName;
_connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5);
_httpClient = CreateHttpClient(_pipeName, _connectTimeout);
}
private static HttpClient CreateHttpClient(string pipeName, TimeSpan connectTimeout)
{
var handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var pipe = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await pipe.ConnectAsync((int)connectTimeout.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
return pipe;
},
ConnectTimeout = connectTimeout
};
return new HttpClient(handler)
{
BaseAddress = new Uri("http://localhost/"),
Timeout = Timeout.InfiniteTimeSpan
};
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_connectTimeout);
var response = await _httpClient.GetAsync("/_ping", cts.Token).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Docker Windows ping failed");
return false;
}
}
public async Task<WindowsRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync("/version", cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var version = JsonSerializer.Deserialize<DockerVersionResponse>(content, JsonOptions);
return new WindowsRuntimeIdentity
{
RuntimeName = "docker",
RuntimeVersion = version?.Version,
OsVersion = version?.Os,
OsBuild = TryParseOsBuild(version?.KernelVersion),
HyperVAvailable = true // Assume available on Windows Server
};
}
public async Task<IReadOnlyList<WindowsContainerInfo>> ListContainersAsync(
WindowsContainerState? stateFilter,
CancellationToken cancellationToken)
{
var url = stateFilter == null || stateFilter == WindowsContainerState.Running
? "/containers/json"
: "/containers/json?all=true";
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var containers = JsonSerializer.Deserialize<List<DockerContainerListItem>>(content, JsonOptions)
?? new List<DockerContainerListItem>();
var result = new List<WindowsContainerInfo>();
foreach (var container in containers)
{
var state = MapDockerState(container.State);
if (stateFilter.HasValue && state != stateFilter.Value)
{
continue;
}
result.Add(new WindowsContainerInfo
{
Id = container.Id,
Name = container.Names?.FirstOrDefault()?.TrimStart('/') ?? container.Id[..12],
ImageRef = container.Image,
ImageId = container.ImageID,
State = state,
ProcessId = 0, // Not available from list
CreatedAt = DateTimeOffset.FromUnixTimeSeconds(container.Created),
Command = container.Command?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
Labels = container.Labels ?? new Dictionary<string, string>(),
RuntimeType = "windows"
});
}
return result;
}
public async Task<WindowsContainerInfo?> GetContainerAsync(string containerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(containerId);
try
{
var response = await _httpClient.GetAsync($"/containers/{containerId}/json", cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var inspect = JsonSerializer.Deserialize<DockerContainerInspectResponse>(content, JsonOptions);
if (inspect == null)
{
return null;
}
return new WindowsContainerInfo
{
Id = inspect.Id,
Name = inspect.Name?.TrimStart('/') ?? inspect.Id[..12],
ImageRef = inspect.Config?.Image,
ImageId = inspect.Image,
State = MapDockerState(inspect.State?.Status),
ProcessId = inspect.State?.Pid ?? 0,
CreatedAt = DateTimeOffset.TryParse(inspect.Created, out var created) ? created : DateTimeOffset.MinValue,
StartedAt = DateTimeOffset.TryParse(inspect.State?.StartedAt, out var started) ? started : null,
FinishedAt = DateTimeOffset.TryParse(inspect.State?.FinishedAt, out var finished) ? finished : null,
ExitCode = inspect.State?.ExitCode,
Command = CombineEntrypointAndCmd(inspect.Config?.Entrypoint, inspect.Config?.Cmd),
Labels = inspect.Config?.Labels ?? new Dictionary<string, string>(),
HyperVIsolated = inspect.HostConfig?.Isolation?.Equals("hyperv", StringComparison.OrdinalIgnoreCase) == true,
RuntimeType = "windows"
};
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
public async IAsyncEnumerable<WindowsContainerEvent> StreamEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var filters = new Dictionary<string, IList<string>>
{
["type"] = new List<string> { "container" },
["event"] = new List<string> { "start", "stop", "die", "destroy", "create" }
};
var filterJson = JsonSerializer.Serialize(filters, JsonOptions);
var url = $"/events?filters={Uri.EscapeDataString(filterJson)}";
_logger.LogDebug("Starting Windows Docker event stream: {Url}", url);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line == null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
DockerEventResponse? evt;
try
{
evt = JsonSerializer.Deserialize<DockerEventResponse>(line, JsonOptions);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse Docker event: {Line}", line);
continue;
}
if (evt == null)
{
continue;
}
yield return new WindowsContainerEvent
{
Type = MapDockerEventAction(evt.Action),
ContainerId = evt.Actor?.Id ?? evt.Id ?? "unknown",
ContainerName = evt.Actor?.Attributes?.GetValueOrDefault("name"),
ImageRef = evt.Actor?.Attributes?.GetValueOrDefault("image") ?? evt.From,
Timestamp = DateTimeOffset.FromUnixTimeSeconds(evt.Time),
Data = evt.Actor?.Attributes
};
}
}
private static WindowsContainerState MapDockerState(string? state)
{
return state?.ToLowerInvariant() switch
{
"created" => WindowsContainerState.Created,
"running" => WindowsContainerState.Running,
"paused" => WindowsContainerState.Paused,
"exited" or "dead" => WindowsContainerState.Stopped,
_ => WindowsContainerState.Unknown
};
}
private static WindowsContainerEventType MapDockerEventAction(string? action)
{
return action?.ToLowerInvariant() switch
{
"create" => WindowsContainerEventType.ContainerCreated,
"start" => WindowsContainerEventType.ContainerStarted,
"stop" or "die" => WindowsContainerEventType.ContainerStopped,
"destroy" => WindowsContainerEventType.ContainerDeleted,
"exec_start" => WindowsContainerEventType.ProcessStarted,
"exec_die" => WindowsContainerEventType.ProcessExited,
_ => WindowsContainerEventType.ContainerCreated
};
}
private static int? TryParseOsBuild(string? kernelVersion)
{
if (string.IsNullOrWhiteSpace(kernelVersion))
{
return null;
}
// Windows kernel version is like "10.0 20348 (20348.1.amd64fre.fe_release.210507-1500)"
var parts = kernelVersion.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && int.TryParse(parts[1], out var build))
{
return build;
}
return null;
}
private static IReadOnlyList<string> CombineEntrypointAndCmd(string[]? entrypoint, string[]? cmd)
{
var result = new List<string>();
if (entrypoint != null)
{
result.AddRange(entrypoint);
}
if (cmd != null)
{
result.AddRange(cmd);
}
return result;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_httpClient.Dispose();
await Task.CompletedTask;
}
#region Docker API DTOs
private sealed class DockerVersionResponse
{
public string? Version { get; set; }
public string? ApiVersion { get; set; }
public string? Os { get; set; }
public string? Arch { get; set; }
public string? KernelVersion { get; set; }
}
private sealed class DockerContainerListItem
{
public string Id { get; set; } = string.Empty;
public string[]? Names { get; set; }
public string? Image { get; set; }
public string? ImageID { get; set; }
public string? Command { get; set; }
public long Created { get; set; }
public string? State { get; set; }
public Dictionary<string, string>? Labels { get; set; }
}
private sealed class DockerContainerInspectResponse
{
public string Id { get; set; } = string.Empty;
public string? Name { get; set; }
public string? Created { get; set; }
public string? Image { get; set; }
public DockerContainerState? State { get; set; }
public DockerContainerConfig? Config { get; set; }
public DockerHostConfig? HostConfig { get; set; }
}
private sealed class DockerContainerState
{
public string? Status { get; set; }
public bool Running { get; set; }
public int Pid { get; set; }
public int ExitCode { get; set; }
public string? StartedAt { get; set; }
public string? FinishedAt { get; set; }
}
private sealed class DockerContainerConfig
{
public string? Image { get; set; }
public string[]? Entrypoint { get; set; }
public string[]? Cmd { get; set; }
public Dictionary<string, string>? Labels { get; set; }
}
private sealed class DockerHostConfig
{
public string? Isolation { get; set; }
}
private sealed class DockerEventResponse
{
public string? Type { get; set; }
public string? Action { get; set; }
public DockerEventActor? Actor { get; set; }
public long Time { get; set; }
public string? Id { get; set; }
public string? From { get; set; }
}
private sealed class DockerEventActor
{
public string? Id { get; set; }
public Dictionary<string, string>? Attributes { get; set; }
}
#endregion
}

View File

@@ -0,0 +1,114 @@
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Client interface for Windows container runtime (HCS or Docker Windows).
/// </summary>
internal interface IWindowsContainerRuntimeClient : IAsyncDisposable
{
/// <summary>
/// Check if the Windows container runtime is available.
/// </summary>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken);
/// <summary>
/// Get runtime identity information.
/// </summary>
Task<WindowsRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken);
/// <summary>
/// List containers matching the specified state filter.
/// </summary>
Task<IReadOnlyList<WindowsContainerInfo>> ListContainersAsync(
WindowsContainerState? stateFilter,
CancellationToken cancellationToken);
/// <summary>
/// Get detailed information about a specific container.
/// </summary>
Task<WindowsContainerInfo?> GetContainerAsync(string containerId, CancellationToken cancellationToken);
/// <summary>
/// Stream container lifecycle events.
/// </summary>
IAsyncEnumerable<WindowsContainerEvent> StreamEventsAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Windows container runtime identity.
/// </summary>
internal sealed class WindowsRuntimeIdentity
{
/// <summary>
/// Runtime name: docker, containerd, hcs.
/// </summary>
public required string RuntimeName { get; init; }
/// <summary>
/// Runtime version.
/// </summary>
public string? RuntimeVersion { get; init; }
/// <summary>
/// Windows OS version (e.g., "10.0.20348").
/// </summary>
public string? OsVersion { get; init; }
/// <summary>
/// Windows OS build number.
/// </summary>
public int? OsBuild { get; init; }
/// <summary>
/// Whether Hyper-V isolation is available.
/// </summary>
public bool HyperVAvailable { get; init; }
}
/// <summary>
/// Windows container lifecycle event.
/// </summary>
internal sealed class WindowsContainerEvent
{
/// <summary>
/// Event type: ContainerCreated, ContainerStarted, ContainerStopped, ContainerDeleted.
/// </summary>
public required WindowsContainerEventType Type { get; init; }
/// <summary>
/// Container ID.
/// </summary>
public required string ContainerId { get; init; }
/// <summary>
/// Container name.
/// </summary>
public string? ContainerName { get; init; }
/// <summary>
/// Container image reference.
/// </summary>
public string? ImageRef { get; init; }
/// <summary>
/// Event timestamp.
/// </summary>
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Additional event data.
/// </summary>
public IReadOnlyDictionary<string, string>? Data { get; init; }
}
/// <summary>
/// Windows container event types.
/// </summary>
internal enum WindowsContainerEventType
{
ContainerCreated,
ContainerStarted,
ContainerStopped,
ContainerDeleted,
ProcessStarted,
ProcessExited
}

View File

@@ -0,0 +1,104 @@
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Windows container information from HCS or Docker Windows API.
/// </summary>
internal sealed class WindowsContainerInfo
{
/// <summary>
/// Container ID (GUID for HCS, or Docker container ID).
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Container name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Container image reference.
/// </summary>
public string? ImageRef { get; init; }
/// <summary>
/// Container image ID/digest.
/// </summary>
public string? ImageId { get; init; }
/// <summary>
/// Container state: Created, Running, Stopped.
/// </summary>
public WindowsContainerState State { get; init; }
/// <summary>
/// Process ID of the container's main process.
/// </summary>
public int ProcessId { get; init; }
/// <summary>
/// Container creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Container start timestamp.
/// </summary>
public DateTimeOffset? StartedAt { get; init; }
/// <summary>
/// Container exit timestamp.
/// </summary>
public DateTimeOffset? FinishedAt { get; init; }
/// <summary>
/// Exit code if container has stopped.
/// </summary>
public int? ExitCode { get; init; }
/// <summary>
/// Container command/entrypoint.
/// </summary>
public IReadOnlyList<string> Command { get; init; } = Array.Empty<string>();
/// <summary>
/// Container labels.
/// </summary>
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Container owner (namespace/pod in Kubernetes).
/// </summary>
public WindowsContainerOwner? Owner { get; init; }
/// <summary>
/// Whether this is a Hyper-V isolated container.
/// </summary>
public bool HyperVIsolated { get; init; }
/// <summary>
/// Runtime type: windows, hyperv.
/// </summary>
public string RuntimeType { get; init; } = "windows";
}
/// <summary>
/// Windows container state.
/// </summary>
internal enum WindowsContainerState
{
Unknown,
Created,
Running,
Paused,
Stopped
}
/// <summary>
/// Windows container owner information (for Kubernetes scenarios).
/// </summary>
internal sealed class WindowsContainerOwner
{
public string? Kind { get; init; }
public string? Name { get; init; }
public string? Namespace { get; init; }
}

View File

@@ -0,0 +1,179 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Collects loaded library hashes from Windows processes (PE format).
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class WindowsLibraryHashCollector
{
private readonly ILogger<WindowsLibraryHashCollector> _logger;
private readonly int _maxLibraries;
private readonly long _maxFileBytes;
private readonly long _maxTotalHashBytes;
public WindowsLibraryHashCollector(
ILogger<WindowsLibraryHashCollector> logger,
int maxLibraries = 256,
long maxFileBytes = 33554432, // 32 MiB
long maxTotalHashBytes = 64_000_000) // ~61 MiB
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_maxLibraries = maxLibraries;
_maxFileBytes = maxFileBytes;
_maxTotalHashBytes = maxTotalHashBytes;
}
/// <summary>
/// Collect loaded libraries from a Windows process.
/// </summary>
public async Task<IReadOnlyList<WindowsLoadedLibrary>> CollectAsync(
int processId,
CancellationToken cancellationToken)
{
var libraries = new List<WindowsLoadedLibrary>();
var totalBytesHashed = 0L;
try
{
using var process = Process.GetProcessById(processId);
var modules = GetProcessModules(process);
foreach (var module in modules.Take(_maxLibraries))
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(module.Path))
{
continue;
}
var library = new WindowsLoadedLibrary
{
Path = module.Path,
ModuleName = module.ModuleName,
BaseAddress = module.BaseAddress,
ModuleSize = module.ModuleSize
};
// Try to hash the file if within limits
if (File.Exists(module.Path) && totalBytesHashed < _maxTotalHashBytes)
{
try
{
var fileInfo = new FileInfo(module.Path);
if (fileInfo.Length <= _maxFileBytes && totalBytesHashed + fileInfo.Length <= _maxTotalHashBytes)
{
var hash = await ComputeFileHashAsync(module.Path, cancellationToken).ConfigureAwait(false);
library = library with { Sha256 = hash };
totalBytesHashed += fileInfo.Length;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Failed to hash library: {Path}", module.Path);
}
}
libraries.Add(library);
}
}
catch (ArgumentException ex)
{
_logger.LogDebug(ex, "Process {ProcessId} not found or inaccessible", processId);
}
catch (InvalidOperationException ex)
{
_logger.LogDebug(ex, "Process {ProcessId} has exited", processId);
}
catch (System.ComponentModel.Win32Exception ex)
{
_logger.LogDebug(ex, "Access denied to process {ProcessId} modules", processId);
}
return libraries;
}
private static IReadOnlyList<WindowsModuleInfo> GetProcessModules(Process process)
{
var modules = new List<WindowsModuleInfo>();
try
{
foreach (ProcessModule module in process.Modules)
{
modules.Add(new WindowsModuleInfo
{
Path = module.FileName,
ModuleName = module.ModuleName,
BaseAddress = module.BaseAddress.ToInt64(),
ModuleSize = module.ModuleMemorySize
});
}
}
catch (System.ComponentModel.Win32Exception)
{
// Access denied - return what we have
}
return modules;
}
private static async Task<string> ComputeFileHashAsync(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
bufferSize: 81920,
useAsync: true);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private sealed class WindowsModuleInfo
{
public string Path { get; init; } = string.Empty;
public string ModuleName { get; init; } = string.Empty;
public long BaseAddress { get; init; }
public int ModuleSize { get; init; }
}
}
/// <summary>
/// Loaded library information from a Windows process.
/// </summary>
internal sealed record WindowsLoadedLibrary
{
/// <summary>
/// Full path to the DLL/EXE.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Module name (filename without path).
/// </summary>
public string? ModuleName { get; init; }
/// <summary>
/// Base address where the module is loaded.
/// </summary>
public long BaseAddress { get; init; }
/// <summary>
/// Size of the module in memory.
/// </summary>
public int ModuleSize { get; init; }
/// <summary>
/// SHA-256 hash of the file (sha256:...).
/// </summary>
public string? Sha256 { get; init; }
}

View File

@@ -0,0 +1,418 @@
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
/// <summary>
/// Collects Java classpath information from a running JVM process.
/// Parses /proc/<pid>/cmdline for -cp/-classpath arguments and extracts JAR metadata.
/// </summary>
internal sealed partial class JavaClasspathCollector
{
private static readonly Regex JavaRegex = GenerateJavaRegex();
private static readonly char[] ClasspathSeparators = { ':', ';' };
private const int MaxJarFiles = 256;
private const long MaxJarSize = 100 * 1024 * 1024; // 100 MiB
private readonly string _procRoot;
private readonly ILogger<JavaClasspathCollector> _logger;
public JavaClasspathCollector(string procRoot, ILogger<JavaClasspathCollector> logger)
{
_procRoot = procRoot?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
?? throw new ArgumentNullException(nameof(procRoot));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Check if a process appears to be a Java process.
/// </summary>
public async Task<bool> IsJavaProcessAsync(int pid, CancellationToken cancellationToken)
{
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
if (cmdline.Count == 0)
{
return false;
}
return JavaRegex.IsMatch(cmdline[0]);
}
/// <summary>
/// Collect classpath entries from a Java process.
/// </summary>
public async Task<IReadOnlyList<ClasspathEntry>> CollectAsync(int pid, CancellationToken cancellationToken)
{
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
if (cmdline.Count == 0)
{
return Array.Empty<ClasspathEntry>();
}
if (!JavaRegex.IsMatch(cmdline[0]))
{
_logger.LogDebug("Process {Pid} is not a Java process", pid);
return Array.Empty<ClasspathEntry>();
}
var classpathValue = ExtractClasspath(cmdline);
if (string.IsNullOrWhiteSpace(classpathValue))
{
// Try to find classpath from environment or use jcmd if available
classpathValue = await TryGetClasspathFromJcmdAsync(pid, cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(classpathValue))
{
_logger.LogDebug("No classpath found for Java process {Pid}", pid);
return Array.Empty<ClasspathEntry>();
}
var entries = new List<ClasspathEntry>();
var paths = classpathValue.Split(ClasspathSeparators, StringSplitOptions.RemoveEmptyEntries);
foreach (var path in paths.Take(MaxJarFiles))
{
cancellationToken.ThrowIfCancellationRequested();
var entry = await ProcessClasspathEntryAsync(path.Trim(), cancellationToken).ConfigureAwait(false);
if (entry != null)
{
entries.Add(entry);
}
}
_logger.LogDebug("Collected {Count} classpath entries for Java process {Pid}", entries.Count, pid);
return entries;
}
private async Task<List<string>> ReadCmdlineAsync(int pid, CancellationToken cancellationToken)
{
var cmdlinePath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cmdline");
if (!File.Exists(cmdlinePath))
{
return new List<string>();
}
try
{
var content = await File.ReadAllBytesAsync(cmdlinePath, cancellationToken).ConfigureAwait(false);
if (content.Length == 0)
{
return new List<string>();
}
return Encoding.UTF8.GetString(content)
.Split('\0', StringSplitOptions.RemoveEmptyEntries)
.ToList();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogDebug(ex, "Failed to read cmdline for PID {Pid}", pid);
return new List<string>();
}
}
private static string? ExtractClasspath(IReadOnlyList<string> cmdline)
{
for (var i = 0; i < cmdline.Count; i++)
{
var arg = cmdline[i];
// -cp <classpath> or -classpath <classpath>
if ((string.Equals(arg, "-cp", StringComparison.Ordinal) ||
string.Equals(arg, "-classpath", StringComparison.Ordinal)) &&
i + 1 < cmdline.Count)
{
return cmdline[i + 1];
}
// -cp:<classpath> or -classpath:<classpath> (some JVMs)
if (arg.StartsWith("-cp:", StringComparison.Ordinal))
{
return arg[4..];
}
if (arg.StartsWith("-classpath:", StringComparison.Ordinal))
{
return arg[11..];
}
// -jar <jarfile> - the jar file is effectively the classpath
if (string.Equals(arg, "-jar", StringComparison.Ordinal) && i + 1 < cmdline.Count)
{
return cmdline[i + 1];
}
}
return null;
}
private async Task<string?> TryGetClasspathFromJcmdAsync(int pid, CancellationToken cancellationToken)
{
// Try to use jcmd to get the classpath
// This requires jcmd to be available and the process to be accessible
try
{
var jcmdPath = FindJcmd();
if (jcmdPath == null)
{
return null;
}
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = jcmdPath,
Arguments = $"{pid} VM.system_properties",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = System.Diagnostics.Process.Start(startInfo);
if (process == null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
return null;
}
// Parse java.class.path from output
foreach (var line in output.Split('\n'))
{
if (line.StartsWith("java.class.path=", StringComparison.Ordinal))
{
return line[16..].Trim();
}
}
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get classpath from jcmd for PID {Pid}", pid);
return null;
}
}
private static string? FindJcmd()
{
var javaHome = Environment.GetEnvironmentVariable("JAVA_HOME");
if (!string.IsNullOrWhiteSpace(javaHome))
{
var jcmdPath = Path.Combine(javaHome, "bin", "jcmd");
if (File.Exists(jcmdPath))
{
return jcmdPath;
}
}
// Try common paths
var paths = new[]
{
"/usr/bin/jcmd",
"/usr/local/bin/jcmd",
"/opt/java/bin/jcmd"
};
return paths.FirstOrDefault(File.Exists);
}
private async Task<ClasspathEntry?> ProcessClasspathEntryAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var type = DetermineEntryType(path);
if (type == "jar" && File.Exists(path))
{
return await ProcessJarFileAsync(path, cancellationToken).ConfigureAwait(false);
}
if (type == "directory" && Directory.Exists(path))
{
return new ClasspathEntry
{
Path = path,
Type = "directory"
};
}
// Entry doesn't exist or is a wildcard
return new ClasspathEntry
{
Path = path,
Type = type
};
}
private async Task<ClasspathEntry> ProcessJarFileAsync(string jarPath, CancellationToken cancellationToken)
{
var entry = new ClasspathEntry
{
Path = jarPath,
Type = "jar"
};
try
{
var fileInfo = new FileInfo(jarPath);
entry = entry with { SizeBytes = fileInfo.Length };
if (fileInfo.Length <= MaxJarSize)
{
// Compute hash
var hash = await ComputeFileHashAsync(jarPath, cancellationToken).ConfigureAwait(false);
entry = entry with { Sha256 = hash };
// Try to extract Maven coordinates from manifest
var (groupId, artifactId, version) = await ExtractMavenCoordinatesAsync(jarPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
{
var coordinate = string.IsNullOrWhiteSpace(version)
? $"{groupId}:{artifactId}"
: $"{groupId}:{artifactId}:{version}";
entry = entry with
{
MavenCoordinate = coordinate,
Purl = $"pkg:maven/{groupId}/{artifactId}" + (string.IsNullOrWhiteSpace(version) ? "" : $"@{version}")
};
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
_logger.LogDebug(ex, "Failed to process JAR file: {Path}", jarPath);
}
return entry;
}
private static async Task<string> ComputeFileHashAsync(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static async Task<(string? groupId, string? artifactId, string? version)> ExtractMavenCoordinatesAsync(
string jarPath,
CancellationToken cancellationToken)
{
try
{
await using var stream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
// Try to find pom.properties in META-INF/maven/
var pomProperties = archive.Entries
.FirstOrDefault(e => e.FullName.EndsWith("pom.properties", StringComparison.OrdinalIgnoreCase) &&
e.FullName.StartsWith("META-INF/maven/", StringComparison.OrdinalIgnoreCase));
if (pomProperties != null)
{
await using var entryStream = pomProperties.Open();
using var reader = new StreamReader(entryStream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
string? groupId = null, artifactId = null, version = null;
foreach (var line in content.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("groupId=", StringComparison.Ordinal))
{
groupId = trimmed[8..];
}
else if (trimmed.StartsWith("artifactId=", StringComparison.Ordinal))
{
artifactId = trimmed[11..];
}
else if (trimmed.StartsWith("version=", StringComparison.Ordinal))
{
version = trimmed[8..];
}
}
return (groupId, artifactId, version);
}
// Fallback: try to parse from MANIFEST.MF
var manifest = archive.GetEntry("META-INF/MANIFEST.MF");
if (manifest != null)
{
await using var entryStream = manifest.Open();
using var reader = new StreamReader(entryStream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
string? implTitle = null, implVersion = null, implVendor = null;
foreach (var line in content.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Implementation-Title:", StringComparison.OrdinalIgnoreCase))
{
implTitle = trimmed[21..].Trim();
}
else if (trimmed.StartsWith("Implementation-Version:", StringComparison.OrdinalIgnoreCase))
{
implVersion = trimmed[23..].Trim();
}
else if (trimmed.StartsWith("Implementation-Vendor-Id:", StringComparison.OrdinalIgnoreCase))
{
implVendor = trimmed[25..].Trim();
}
}
if (!string.IsNullOrWhiteSpace(implTitle))
{
return (implVendor, implTitle, implVersion);
}
}
}
catch
{
// Ignore errors extracting coordinates
}
return (null, null, null);
}
private static string DetermineEntryType(string path)
{
if (path.EndsWith(".jar", StringComparison.OrdinalIgnoreCase))
{
return "jar";
}
if (path.EndsWith(".jmod", StringComparison.OrdinalIgnoreCase))
{
return "jmod";
}
if (path.EndsWith("/*", StringComparison.Ordinal) || path.EndsWith("\\*", StringComparison.Ordinal))
{
return "wildcard";
}
return "directory";
}
[GeneratedRegex(@"(^|/)(java|javaw)(\.exe)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex GenerateJavaRegex();
}