747 lines
30 KiB
C#
747 lines
30 KiB
C#
using FluentAssertions;
|
|
using System.Text.RegularExpressions;
|
|
using Xunit;
|
|
using YamlDotNet.Core;
|
|
using YamlDotNet.RepresentationModel;
|
|
|
|
namespace BluejayInfraLint.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FleetManifestLintTests
|
|
{
|
|
private static readonly ManifestInventory Inventory = ManifestInventory.Load();
|
|
|
|
private static readonly HashSet<string> PublicReadOnlyHosts = new(StringComparer.Ordinal)
|
|
{
|
|
"dist.flowercore.io",
|
|
"dns.iamworkin.lan",
|
|
};
|
|
|
|
// Public hosts that allow a tightly bounded write surface in addition to
|
|
// GET/HEAD. updatecenter.iamworkin.lan accepts POST /api/v1/checkin/{id}
|
|
// (bootstrap-JWT) so its allowlist is GET||HEAD||POST||OPTIONS — but
|
|
// PUT/PATCH/DELETE must still 404 at the route. Anything wider than this
|
|
// set should fail this lint.
|
|
//
|
|
// PUB-1 (2026-05-06): update.flowercore.io / updates.flowercore.io were
|
|
// added for the Cloudflare-proxied public Update Center edge. They use the
|
|
// same bounded read-write allowlist as the LAN pair.
|
|
private static readonly HashSet<string> PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal)
|
|
{
|
|
"updatecenter.iamworkin.lan",
|
|
"updates.iamworkin.lan",
|
|
"update.flowercore.io",
|
|
"updates.flowercore.io",
|
|
};
|
|
|
|
private static readonly HashSet<string> ApiKeyProtectedDeployments = new(StringComparer.Ordinal)
|
|
{
|
|
"messageboard-web",
|
|
"scoreboard-web",
|
|
"segmentdisplay-web",
|
|
"signalcontrol-web",
|
|
};
|
|
|
|
private static readonly HashSet<string> PublicEgressDeployments = new(StringComparer.Ordinal)
|
|
{
|
|
"asterisk",
|
|
"fc-llm-bridge",
|
|
"mysql-web",
|
|
"php-web",
|
|
"ttsreader-align",
|
|
"ttsreader-kokoro",
|
|
"ttsreader-modern",
|
|
"ttsreader-piper",
|
|
};
|
|
|
|
private static readonly IReadOnlyDictionary<string, string> TopLinuxRunnerRepos = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["github-runner-puppet"] = "https://github.com/astoltz/FlowerCore.Puppet",
|
|
["github-runner-signage"] = "https://github.com/astoltz/FlowerCore.Signage",
|
|
["github-runner-dms"] = "https://github.com/astoltz/FlowerCore.DMS",
|
|
["github-runner-telephony"] = "https://github.com/astoltz/FlowerCore.Telephony",
|
|
["github-runner-print-web"] = "https://github.com/astoltz/FlowerCore.Print.Web",
|
|
["github-runner-chat"] = "https://github.com/astoltz/FlowerCore.Chat",
|
|
["github-runner-mysql"] = "https://github.com/astoltz/FlowerCore.MySQL",
|
|
["github-runner-kiosk-linux"] = "https://github.com/astoltz/FlowerCore.Kiosk.Linux",
|
|
};
|
|
|
|
[Fact]
|
|
public void IngressRoutes_MustKeepServiceReferencesInTheSameNamespace()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.Kind == "IngressRoute")
|
|
.SelectMany(document =>
|
|
document.MappingSequence("spec", "routes")
|
|
.SelectMany(route =>
|
|
route.MappingSequence("services")
|
|
.Select(service => new
|
|
{
|
|
Document = document,
|
|
ServiceName = ManifestNodeExtensions.Scalar(service, "name"),
|
|
ServiceNamespace = ManifestNodeExtensions.Scalar(service, "namespace"),
|
|
})))
|
|
.Where(entry => !string.IsNullOrWhiteSpace(entry.ServiceNamespace))
|
|
.Where(entry => !string.Equals(entry.ServiceNamespace, entry.Document.Namespace, StringComparison.Ordinal))
|
|
.Select(entry =>
|
|
$"{entry.Document.Descriptor} references Service '{entry.ServiceName}' in namespace '{entry.ServiceNamespace}'.")
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void PublicReadOnlyIngressRoutes_MustExplicitlyAllowOnlyGetAndHead()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.Kind == "IngressRoute")
|
|
.SelectMany(document =>
|
|
document.MappingSequence("spec", "routes")
|
|
.Select(route => new
|
|
{
|
|
Document = document,
|
|
Match = ManifestNodeExtensions.Scalar(route, "match") ?? string.Empty,
|
|
}))
|
|
.Where(entry => PublicReadOnlyHosts.Any(host => entry.Match.Contains($"Host(`{host}`)", StringComparison.Ordinal)))
|
|
.Where(entry => !entry.Match.Contains("Method(`GET`)", StringComparison.Ordinal)
|
|
|| !entry.Match.Contains("Method(`HEAD`)", StringComparison.Ordinal))
|
|
.Select(entry => $"{entry.Document.Descriptor} is missing an explicit GET/HEAD method allowlist.")
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void PublicReadWriteIngressRoutes_MustPinGetHeadPostOptionsAllowlist()
|
|
{
|
|
// For hosts in PublicReadWriteAllowlistHosts, the route match MUST
|
|
// contain Method(`GET`), Method(`HEAD`), Method(`POST`), and
|
|
// Method(`OPTIONS`) AND MUST NOT contain Method(`PUT`),
|
|
// Method(`PATCH`), or Method(`DELETE`). This keeps the public
|
|
// allowlist invariant against regression — see Track A's
|
|
// updatecenter-web ingressroute hardening.
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.Kind == "IngressRoute")
|
|
.SelectMany(document =>
|
|
document.MappingSequence("spec", "routes")
|
|
.Select(route => new
|
|
{
|
|
Document = document,
|
|
Match = ManifestNodeExtensions.Scalar(route, "match") ?? string.Empty,
|
|
}))
|
|
.Where(entry => PublicReadWriteAllowlistHosts.Any(host => entry.Match.Contains($"Host(`{host}`)", StringComparison.Ordinal)))
|
|
.SelectMany(entry =>
|
|
{
|
|
var localViolations = new List<string>();
|
|
|
|
foreach (var required in new[] { "GET", "HEAD", "POST", "OPTIONS" })
|
|
{
|
|
if (!entry.Match.Contains($"Method(`{required}`)", StringComparison.Ordinal))
|
|
{
|
|
localViolations.Add($"{entry.Document.Descriptor} is missing required Method(`{required}`).");
|
|
}
|
|
}
|
|
|
|
foreach (var forbidden in new[] { "PUT", "PATCH", "DELETE" })
|
|
{
|
|
if (entry.Match.Contains($"Method(`{forbidden}`)", StringComparison.Ordinal))
|
|
{
|
|
localViolations.Add($"{entry.Document.Descriptor} must not include Method(`{forbidden}`) on a public host.");
|
|
}
|
|
}
|
|
|
|
return localViolations;
|
|
})
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void TraefikVipNetworkPolicies_MustAllowPostDnatBackendPorts()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.Kind == "NetworkPolicy")
|
|
.Where(document => document.AllScalars().Any(value => value.Contains("10.0.56.200", StringComparison.Ordinal)))
|
|
.SelectMany(document =>
|
|
{
|
|
var ports = document.EgressPorts().ToHashSet(StringComparer.Ordinal);
|
|
var localViolations = new List<string>();
|
|
|
|
if (ports.Contains("443") && !ports.Contains("8443"))
|
|
{
|
|
localViolations.Add($"{document.Descriptor} allows Traefik VIP 443 without backend port 8443.");
|
|
}
|
|
|
|
if (ports.Contains("80") && !ports.Contains("8000") && !ports.Contains("8080"))
|
|
{
|
|
localViolations.Add($"{document.Descriptor} allows Traefik VIP 80 without a backend HTTP port (8000/8080).");
|
|
}
|
|
|
|
return localViolations;
|
|
})
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ApiKeyProtectedDeployments_MustUseTcpSocketHealthProbes()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.Kind == "Deployment")
|
|
.Where(document => ApiKeyProtectedDeployments.Contains(document.Name))
|
|
.SelectMany(document => document.ContainerMappings().SelectMany(container =>
|
|
ProbeViolations(document, container, "readinessProbe")
|
|
.Concat(ProbeViolations(document, container, "livenessProbe"))))
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void GitHubRunnerFleet_MustRegisterTopLinuxReposAsRepoScopedDeployments()
|
|
{
|
|
var deployments = Inventory.Documents
|
|
.Where(document => document.Kind == "Deployment")
|
|
.Where(document => document.Namespace == "github-runner")
|
|
.ToDictionary(document => document.Name, StringComparer.Ordinal);
|
|
|
|
foreach (var expectedRunner in TopLinuxRunnerRepos)
|
|
{
|
|
deployments.Should().ContainKey(expectedRunner.Key);
|
|
|
|
var container = deployments[expectedRunner.Key].ContainerMappings().Should().ContainSingle().Subject;
|
|
EnvValue(container, "REPO_URL").Should().Be(expectedRunner.Value);
|
|
EnvValue(container, "EPHEMERAL").Should().Be("true");
|
|
EnvValue(container, "LABELS").Should().Be("self-hosted,linux,fc-build-linux");
|
|
EnvValue(container, "ACCESS_TOKEN").Should().BeNull("ACCESS_TOKEN must come from github-runner-token Secret, not a literal");
|
|
EnvSecretName(container, "ACCESS_TOKEN").Should().Be("github-runner-token");
|
|
EnvSecretKey(container, "ACCESS_TOKEN").Should().Be("credential");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void GitHubRunnerFleet_MustPreserveExistingCommonRunnerShape()
|
|
{
|
|
var common = Inventory.Documents
|
|
.Single(document => document.Kind == "Deployment"
|
|
&& document.Namespace == "github-runner"
|
|
&& document.Name == "github-runner");
|
|
|
|
var container = common.ContainerMappings().Should().ContainSingle().Subject;
|
|
EnvValue(container, "REPO_URL").Should().Be("https://github.com/astoltz/FlowerCore.Common");
|
|
EnvValue(container, "RUNNER_NAME_PREFIX").Should().Be("rke2-linux");
|
|
EnvValue(container, "LABELS").Should().Be("self-hosted,linux,fc-build-linux");
|
|
|
|
var claimNames = common.MappingSequence("spec", "template", "spec", "volumes")
|
|
.Select(volume => ManifestNodeExtensions.Scalar(volume, "persistentVolumeClaim", "claimName"))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.ToList();
|
|
|
|
claimNames.Should().Contain("github-runner-nuget-cache");
|
|
}
|
|
|
|
[Fact]
|
|
public void GitHubRunnerFleet_MustUseOneRwoCachePerRepoScopedDeployment()
|
|
{
|
|
var pvcNames = Inventory.Documents
|
|
.Where(document => document.Kind == "PersistentVolumeClaim")
|
|
.Where(document => document.Namespace == "github-runner")
|
|
.Select(document => document.Name)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
foreach (var deploymentName in TopLinuxRunnerRepos.Keys)
|
|
{
|
|
var suffix = deploymentName["github-runner-".Length..];
|
|
pvcNames.Should().Contain($"github-runner-{suffix}-nuget-cache");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Monitoring_MustAlertWhenTopLinuxRunnerDeploymentIsUnavailable()
|
|
{
|
|
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
|
|
|
|
monitoring.Should().Contain("LinuxRunnerOffline");
|
|
monitoring.Should().Contain("kube_deployment_status_replicas_available{namespace=\"github-runner\"");
|
|
monitoring.Should().Contain("github-runner(|-(puppet|signage|dms|telephony|print-web|chat|mysql|kiosk-linux))");
|
|
monitoring.Should().Contain("runbook_url: \"https://gitea.iamworkin.lan/bluejay/FlowerCore.Notes/src/branch/master/docs/infrastructure/self-hosted-runner-fleet.md\"");
|
|
}
|
|
|
|
[Fact]
|
|
public void StatefulSets_WithVolumeClaimTemplates_MustDeclareFilesystemDefaults()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.Kind == "StatefulSet")
|
|
.Where(document => document.MappingSequence("spec", "volumeClaimTemplates").Count > 0)
|
|
.SelectMany(document =>
|
|
{
|
|
var localViolations = new List<string>();
|
|
|
|
if (string.IsNullOrWhiteSpace(document.Scalar("spec", "podManagementPolicy")))
|
|
{
|
|
localViolations.Add($"{document.Descriptor} is missing spec.podManagementPolicy.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(document.Scalar("spec", "revisionHistoryLimit")))
|
|
{
|
|
localViolations.Add($"{document.Descriptor} is missing spec.revisionHistoryLimit.");
|
|
}
|
|
|
|
foreach (var claimTemplate in document.MappingSequence("spec", "volumeClaimTemplates"))
|
|
{
|
|
if (!string.Equals(
|
|
ManifestNodeExtensions.Scalar(claimTemplate, "spec", "volumeMode"),
|
|
"Filesystem",
|
|
StringComparison.Ordinal))
|
|
{
|
|
var claimName = ManifestNodeExtensions.Scalar(claimTemplate, "metadata", "name") ?? "<unnamed>";
|
|
localViolations.Add($"{document.Descriptor} volumeClaimTemplate '{claimName}' is missing volumeMode: Filesystem.");
|
|
}
|
|
}
|
|
|
|
return localViolations;
|
|
})
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void LocallyImportedImages_MustUseLocalhostPrefixAndNeverPullPolicy()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.PodSpec() is not null)
|
|
.SelectMany(document => document.ContainerSpecs()
|
|
.Where(container => !string.IsNullOrWhiteSpace(container.Image))
|
|
.Select(container => new
|
|
{
|
|
Document = document,
|
|
Container = container,
|
|
}))
|
|
.Where(entry =>
|
|
(entry.Container.Image.StartsWith("localhost/", StringComparison.Ordinal)
|
|
&& !string.Equals(entry.Container.ImagePullPolicy, "Never", StringComparison.Ordinal))
|
|
|| (entry.Container.Image.StartsWith("fc-", StringComparison.Ordinal)
|
|
&& !entry.Container.Image.Contains('/', StringComparison.Ordinal)))
|
|
.Select(entry =>
|
|
{
|
|
if (entry.Container.Image.StartsWith("localhost/", StringComparison.Ordinal))
|
|
{
|
|
return $"{entry.Document.Descriptor} container '{entry.Container.Name}' uses {entry.Container.Image} without imagePullPolicy: Never.";
|
|
}
|
|
|
|
return $"{entry.Document.Descriptor} container '{entry.Container.Name}' uses non-local image '{entry.Container.Image}' for a node-imported FlowerCore workload.";
|
|
})
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void PublicEgressDeployments_MustOptOutOfIamworkinLanSearchSuffixes()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.PodSpec() is not null)
|
|
.Where(document => PublicEgressDeployments.Contains(document.Name))
|
|
.SelectMany(document =>
|
|
{
|
|
var localViolations = new List<string>();
|
|
var podSpec = document.PodSpec()!;
|
|
var dnsPolicy = ManifestNodeExtensions.Scalar(podSpec, "dnsPolicy");
|
|
var searches = ManifestNodeExtensions.ScalarSequence(podSpec, "dnsConfig", "searches").ToList();
|
|
|
|
if (!string.Equals(dnsPolicy, "None", StringComparison.Ordinal))
|
|
{
|
|
localViolations.Add($"{document.Descriptor} is missing dnsPolicy: None.");
|
|
}
|
|
|
|
if (searches.Count == 0)
|
|
{
|
|
localViolations.Add($"{document.Descriptor} is missing dnsConfig.searches.");
|
|
}
|
|
else if (searches.Any(search => search.Contains("iamworkin.lan", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
localViolations.Add($"{document.Descriptor} still includes iamworkin.lan in dnsConfig.searches.");
|
|
}
|
|
|
|
return localViolations;
|
|
})
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
private static IEnumerable<string> ProbeViolations(
|
|
ManifestDocument document,
|
|
YamlMappingNode container,
|
|
string probeKey)
|
|
{
|
|
if (!ManifestNodeExtensions.TryGetMapping(container, probeKey, out var probe)
|
|
|| !ManifestNodeExtensions.TryGetMapping(probe, "httpGet", out var httpGet))
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var path = ManifestNodeExtensions.Scalar(httpGet, "path");
|
|
if (!string.Equals(path, "/health", StringComparison.Ordinal))
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var containerName = ManifestNodeExtensions.Scalar(container, "name") ?? "<unnamed>";
|
|
return new[]
|
|
{
|
|
$"{document.Descriptor} container '{containerName}' still uses {probeKey}.httpGet on /health.",
|
|
};
|
|
}
|
|
|
|
private static string? EnvValue(YamlMappingNode container, string name)
|
|
{
|
|
return EnvMapping(container, name) is { } env ? ManifestNodeExtensions.Scalar(env, "value") : null;
|
|
}
|
|
|
|
private static string? EnvSecretName(YamlMappingNode container, string name)
|
|
{
|
|
return EnvMapping(container, name) is { } env
|
|
? ManifestNodeExtensions.Scalar(env, "valueFrom", "secretKeyRef", "name")
|
|
: null;
|
|
}
|
|
|
|
private static string? EnvSecretKey(YamlMappingNode container, string name)
|
|
{
|
|
return EnvMapping(container, name) is { } env
|
|
? ManifestNodeExtensions.Scalar(env, "valueFrom", "secretKeyRef", "key")
|
|
: null;
|
|
}
|
|
|
|
private static YamlMappingNode? EnvMapping(YamlMappingNode container, string name)
|
|
{
|
|
return ManifestNodeExtensions.MappingSequence(container, "env")
|
|
.SingleOrDefault(env => string.Equals(ManifestNodeExtensions.Scalar(env, "name"), name, StringComparison.Ordinal));
|
|
}
|
|
}
|
|
|
|
internal sealed class ManifestInventory
|
|
{
|
|
private ManifestInventory(string workspaceRoot, string bluejayRoot, IReadOnlyList<ManifestDocument> documents)
|
|
{
|
|
WorkspaceRoot = workspaceRoot;
|
|
BluejayRoot = bluejayRoot;
|
|
Documents = documents;
|
|
}
|
|
|
|
public string WorkspaceRoot { get; }
|
|
|
|
public string BluejayRoot { get; }
|
|
|
|
public IReadOnlyList<ManifestDocument> Documents { get; }
|
|
|
|
public static ManifestInventory Load()
|
|
{
|
|
var bluejayRoot = FindBluejayInfraRoot();
|
|
var workspaceRoot = Directory.GetParent(bluejayRoot)?.FullName
|
|
?? throw new DirectoryNotFoundException($"Could not resolve workspace root from '{bluejayRoot}'.");
|
|
|
|
var documents = ManifestRoots(workspaceRoot, bluejayRoot)
|
|
.SelectMany(LoadDocumentsFromRoot)
|
|
.ToList();
|
|
|
|
return new ManifestInventory(workspaceRoot, bluejayRoot, documents);
|
|
}
|
|
|
|
private static string FindBluejayInfraRoot()
|
|
{
|
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (current is not null)
|
|
{
|
|
if (Directory.Exists(Path.Combine(current.FullName, "apps"))
|
|
&& File.Exists(Path.Combine(current.FullName, "README.md")))
|
|
{
|
|
return current.FullName;
|
|
}
|
|
|
|
current = current.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not find the bluejay-infra repository root from the test output directory.");
|
|
}
|
|
|
|
private static IEnumerable<string> ManifestRoots(string workspaceRoot, string bluejayRoot)
|
|
{
|
|
var roots = new[]
|
|
{
|
|
Path.Combine(bluejayRoot, "apps"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Chat", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.DMS", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.DNS", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Intranet.Web", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Kiosk", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Media", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.MenuBoard", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.MessageBoard", "k8s"),
|
|
// FlowerCore.Notes/k8s/selenium/ is the live Selenium Grid
|
|
// manifest tree (consumed by deploy-selenium scripts).
|
|
// FlowerCore.Notes/k8s/guacamole/ + FlowerCore.Notes/k8s/monitoring/
|
|
// are historical scaffolds that have diverged from the live state
|
|
// (bluejay-infra/apps/guacamole + bluejay-infra/apps/monitoring are
|
|
// canonical). Operator review is required before bringing them in
|
|
// line OR decommissioning them — keep them out of the lint scope
|
|
// until that decision lands. See xxl-regroup-2026-05-03-followup.md
|
|
// "Codex 7 §0 stop conditions" + the C7 close-session output.
|
|
Path.Combine(workspaceRoot, "FlowerCore.Notes", "k8s", "selenium"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.MySQL", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.PHP", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Presentations", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Print.Web", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.RemoteDesktop", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Scoreboard", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.SegmentDisplay", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.SignalControl", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.TtsReader", "k8s"),
|
|
Path.Combine(workspaceRoot, "FlowerCore.Updater", "k8s"),
|
|
};
|
|
|
|
return roots.Where(Directory.Exists);
|
|
}
|
|
|
|
private static IEnumerable<ManifestDocument> LoadDocumentsFromRoot(string root)
|
|
{
|
|
foreach (var filePath in Directory.EnumerateFiles(root, "*.yaml", SearchOption.AllDirectories))
|
|
{
|
|
var fileText = File.ReadAllText(filePath);
|
|
var segments = SplitManifestDocuments(fileText);
|
|
|
|
for (var index = 0; index < segments.Count; index++)
|
|
{
|
|
var yaml = new YamlStream();
|
|
try
|
|
{
|
|
using var reader = new StringReader(segments[index]);
|
|
yaml.Load(reader);
|
|
}
|
|
catch (YamlException exception)
|
|
{
|
|
_ = exception;
|
|
continue;
|
|
}
|
|
|
|
if (yaml.Documents.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (yaml.Documents[0].RootNode is YamlMappingNode mapping
|
|
&& ManifestNodeExtensions.Scalar(mapping, "kind") is not null)
|
|
{
|
|
yield return new ManifestDocument(root, filePath, index, fileText, mapping);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<string> SplitManifestDocuments(string fileText)
|
|
{
|
|
var documents = new List<string>();
|
|
var currentLines = new List<string>();
|
|
var seenApiVersion = false;
|
|
|
|
foreach (var line in Regex.Split(fileText, @"\r?\n"))
|
|
{
|
|
if (Regex.IsMatch(line, @"^\s*---\s*$"))
|
|
{
|
|
FlushCurrentDocument();
|
|
continue;
|
|
}
|
|
|
|
if (Regex.IsMatch(line, @"^\s*apiVersion:\s*")
|
|
&& seenApiVersion
|
|
&& currentLines.Any(existing => !string.IsNullOrWhiteSpace(existing)))
|
|
{
|
|
FlushCurrentDocument();
|
|
}
|
|
|
|
currentLines.Add(line);
|
|
if (Regex.IsMatch(line, @"^\s*apiVersion:\s*"))
|
|
{
|
|
seenApiVersion = true;
|
|
}
|
|
}
|
|
|
|
FlushCurrentDocument();
|
|
return documents;
|
|
|
|
void FlushCurrentDocument()
|
|
{
|
|
var text = string.Join(Environment.NewLine, currentLines).Trim();
|
|
if (!string.IsNullOrWhiteSpace(text))
|
|
{
|
|
documents.Add(text);
|
|
}
|
|
|
|
currentLines.Clear();
|
|
seenApiVersion = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed record ManifestDocument(
|
|
string RootPath,
|
|
string FilePath,
|
|
int DocumentIndex,
|
|
string FileText,
|
|
YamlMappingNode Root)
|
|
{
|
|
public string Kind => Scalar("kind") ?? string.Empty;
|
|
|
|
public string Name => Scalar("metadata", "name") ?? $"document-{DocumentIndex}";
|
|
|
|
public string Namespace => Scalar("metadata", "namespace") ?? string.Empty;
|
|
|
|
public string RelativePath => Path.GetRelativePath(RootPath, FilePath).Replace('\\', '/');
|
|
|
|
public string Descriptor => $"{Kind} {Namespace}/{Name} [{RelativePath}#{DocumentIndex + 1}]";
|
|
|
|
public string? Scalar(params string[] path) => ManifestNodeExtensions.Scalar(Root, path);
|
|
|
|
public IReadOnlyList<YamlMappingNode> MappingSequence(params string[] path) => ManifestNodeExtensions.MappingSequence(Root, path);
|
|
|
|
public IEnumerable<string> AllScalars() => ManifestNodeExtensions.AllScalars(Root);
|
|
|
|
public IReadOnlyList<string> EgressPorts()
|
|
{
|
|
return MappingSequence("spec", "egress")
|
|
.SelectMany(egressRule => ManifestNodeExtensions.MappingSequence(egressRule, "ports"))
|
|
.Select(portMapping => ManifestNodeExtensions.Scalar(portMapping, "port"))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Cast<string>()
|
|
.ToList();
|
|
}
|
|
|
|
public YamlMappingNode? PodSpec()
|
|
{
|
|
return Kind switch
|
|
{
|
|
"Deployment" or "StatefulSet" or "DaemonSet" or "Job" =>
|
|
ManifestNodeExtensions.Mapping(Root, "spec", "template", "spec"),
|
|
"CronJob" =>
|
|
ManifestNodeExtensions.Mapping(Root, "spec", "jobTemplate", "spec", "template", "spec"),
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
public IReadOnlyList<YamlMappingNode> ContainerMappings()
|
|
{
|
|
var podSpec = PodSpec();
|
|
if (podSpec is null)
|
|
{
|
|
return Array.Empty<YamlMappingNode>();
|
|
}
|
|
|
|
return ManifestNodeExtensions.MappingSequence(podSpec, "containers")
|
|
.Concat(ManifestNodeExtensions.MappingSequence(podSpec, "initContainers"))
|
|
.ToList();
|
|
}
|
|
|
|
public IReadOnlyList<ContainerSpec> ContainerSpecs()
|
|
{
|
|
return ContainerMappings()
|
|
.Select(container => new ContainerSpec(
|
|
ManifestNodeExtensions.Scalar(container, "name") ?? "<unnamed>",
|
|
ManifestNodeExtensions.Scalar(container, "image") ?? string.Empty,
|
|
ManifestNodeExtensions.Scalar(container, "imagePullPolicy") ?? string.Empty))
|
|
.ToList();
|
|
}
|
|
}
|
|
|
|
internal sealed record ContainerSpec(string Name, string Image, string ImagePullPolicy);
|
|
|
|
internal static class ManifestNodeExtensions
|
|
{
|
|
public static string? Scalar(this YamlMappingNode mapping, params string[] path)
|
|
{
|
|
return TryGetNode(mapping, path, out var node) && node is YamlScalarNode scalar
|
|
? scalar.Value
|
|
: null;
|
|
}
|
|
|
|
public static YamlMappingNode? Mapping(this YamlMappingNode mapping, params string[] path)
|
|
{
|
|
return TryGetNode(mapping, path, out var node) ? node as YamlMappingNode : null;
|
|
}
|
|
|
|
public static bool TryGetMapping(this YamlMappingNode mapping, string key, out YamlMappingNode result)
|
|
{
|
|
if (TryGetChild(mapping, key, out var child) && child is YamlMappingNode childMapping)
|
|
{
|
|
result = childMapping;
|
|
return true;
|
|
}
|
|
|
|
result = null!;
|
|
return false;
|
|
}
|
|
|
|
public static IReadOnlyList<YamlMappingNode> MappingSequence(this YamlMappingNode mapping, params string[] path)
|
|
{
|
|
return TryGetNode(mapping, path, out var node) && node is YamlSequenceNode sequence
|
|
? sequence.Children.OfType<YamlMappingNode>().ToList()
|
|
: Array.Empty<YamlMappingNode>();
|
|
}
|
|
|
|
public static IReadOnlyList<string> ScalarSequence(this YamlMappingNode mapping, params string[] path)
|
|
{
|
|
return TryGetNode(mapping, path, out var node) && node is YamlSequenceNode sequence
|
|
? sequence.Children.OfType<YamlScalarNode>()
|
|
.Select(child => child.Value)
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Cast<string>()
|
|
.ToList()
|
|
: Array.Empty<string>();
|
|
}
|
|
|
|
public static IEnumerable<string> AllScalars(YamlNode node)
|
|
{
|
|
return node switch
|
|
{
|
|
YamlScalarNode scalar when !string.IsNullOrWhiteSpace(scalar.Value) => new[] { scalar.Value! },
|
|
YamlSequenceNode sequence => sequence.Children.SelectMany(AllScalars),
|
|
YamlMappingNode mapping => mapping.Children.SelectMany(entry => AllScalars(entry.Key).Concat(AllScalars(entry.Value))),
|
|
_ => Array.Empty<string>(),
|
|
};
|
|
}
|
|
|
|
private static bool TryGetNode(YamlMappingNode mapping, IReadOnlyList<string> path, out YamlNode node)
|
|
{
|
|
YamlNode current = mapping;
|
|
foreach (var segment in path)
|
|
{
|
|
if (current is not YamlMappingNode currentMapping || !TryGetChild(currentMapping, segment, out current))
|
|
{
|
|
node = null!;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
node = current;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryGetChild(YamlMappingNode mapping, string key, out YamlNode value)
|
|
{
|
|
foreach (var entry in mapping.Children)
|
|
{
|
|
if (entry.Key is YamlScalarNode scalar
|
|
&& string.Equals(scalar.Value, key, StringComparison.Ordinal))
|
|
{
|
|
value = entry.Value;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
value = null!;
|
|
return false;
|
|
}
|
|
}
|