using System.Text.Json;
namespace RackPeek.Domain.Discovery;
/// A published port binding on the host side.
public sealed record DockerPortBinding(int HostPort, string Protocol, string HostIp = "") {
///
/// Bound to a loopback address, so only the host itself can reach it. Docker
/// writes the address as given (127.0.0.1 mostly, but any 127.x works).
///
public bool IsLoopback =>
HostIp.StartsWith("127.", StringComparison.Ordinal) || HostIp == "::1";
/// Bound to every interface rather than one address.
public bool IsWildcard => HostIp is "" or "0.0.0.0" or "::";
}
///
/// A container, reduced to the parts RackPeek models. Everything here comes from a
/// single GET /containers/json call — the per-container inspect adds nothing
/// a Service resource can hold.
///
public sealed record DockerContainer {
public required string Name { get; init; }
public required string Image { get; init; }
public string? ComposeProject { get; init; }
public IReadOnlyList PublishedPorts { get; init; } = [];
}
/// Parses the Docker Engine list response. Pure, so it tests from a fixture.
public static class DockerContainerParser {
private const string _composeProjectLabel = "com.docker.compose.project";
public static List Parse(string json) {
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
return [];
return document.RootElement.EnumerateArray()
.Select(ParseContainer)
.OfType()
.OrderBy(c => c.Name, StringComparer.Ordinal)
.ToList();
}
private static DockerContainer? ParseContainer(JsonElement element) {
var name = ParseName(element);
if (string.IsNullOrWhiteSpace(name))
return null;
return new DockerContainer {
Name = name,
Image = GetString(element, "Image") ?? "unknown",
ComposeProject = GetLabel(element, _composeProjectLabel),
PublishedPorts = ParsePorts(element)
};
}
private static string? ParseName(JsonElement element) {
if (!element.TryGetProperty("Names", out JsonElement names) || names.ValueKind != JsonValueKind.Array)
return GetString(element, "Name")?.TrimStart('/');
return names.EnumerateArray()
.Select(n => n.GetString()?.TrimStart('/'))
.FirstOrDefault(n => !string.IsNullOrWhiteSpace(n));
}
private static List ParsePorts(JsonElement element) {
if (!element.TryGetProperty("Ports", out JsonElement ports) || ports.ValueKind != JsonValueKind.Array)
return [];
// Dual-stack publishes show up twice, once per family ("0.0.0.0" and "::");
// folding wildcards to one spelling keeps that one binding, not two.
return ports.EnumerateArray()
.Where(p => p.TryGetProperty("PublicPort", out JsonElement port) && port.ValueKind == JsonValueKind.Number)
.Select(p => new DockerPortBinding(
p.GetProperty("PublicPort").GetInt32(),
(GetString(p, "Type") ?? "tcp").ToUpperInvariant(),
GetString(p, "IP") ?? ""))
.Select(p => p.IsWildcard ? p with { HostIp = "" } : p)
.DistinctBy(p => (p.HostPort, p.Protocol, p.HostIp))
.OrderBy(p => p.HostPort)
.ToList();
}
private static string? GetLabel(JsonElement element, string label) {
if (!element.TryGetProperty("Labels", out JsonElement labels) || labels.ValueKind != JsonValueKind.Object)
return null;
return labels.TryGetProperty(label, out JsonElement value) ? value.GetString() : null;
}
private static string? GetString(JsonElement element, string property) =>
element.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}