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
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:
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
213
src/Zastava/StellaOps.Zastava.Agent/Docker/DockerEventModels.cs
Normal file
213
src/Zastava/StellaOps.Zastava.Agent/Docker/DockerEventModels.cs
Normal 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; }
|
||||
}
|
||||
296
src/Zastava/StellaOps.Zastava.Agent/Docker/DockerSocketClient.cs
Normal file
296
src/Zastava/StellaOps.Zastava.Agent/Docker/DockerSocketClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
40
src/Zastava/StellaOps.Zastava.Agent/Program.cs
Normal file
40
src/Zastava/StellaOps.Zastava.Agent/Program.cs
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
300
src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventBuffer.cs
Normal file
300
src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventBuffer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
58
src/Zastava/StellaOps.Zastava.Agent/appsettings.json
Normal file
58
src/Zastava/StellaOps.Zastava.Agent/appsettings.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user