1441 lines
62 KiB
C#
1441 lines
62 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)
|
|
{
|
|
"brochure.flowercore.io",
|
|
"dist.flowercore.io",
|
|
};
|
|
|
|
// 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. Public
|
|
// update.flowercore.io remains a GET/HEAD download surface in the
|
|
// FlowerCore.Updater sibling manifest and is covered by the general
|
|
// public-method allowlist lint instead of this write-surface rule.
|
|
private static readonly HashSet<string> PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal)
|
|
{
|
|
"updatecenter.iamworkin.lan",
|
|
"updates.iamworkin.lan",
|
|
};
|
|
|
|
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> LinuxRunnerRepos = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["github-runner"] = "https://github.com/astoltz/FlowerCore.Common",
|
|
["github-runner-sharedpos"] = "https://github.com/astoltz/FlowerCore.Shared.Pos",
|
|
["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",
|
|
["github-runner-updater"] = "https://github.com/astoltz/FlowerCore.Updater",
|
|
};
|
|
|
|
private static readonly HashSet<string> RepoScopedLinuxRunnerDeployments = new(StringComparer.Ordinal)
|
|
{
|
|
"github-runner-sharedpos",
|
|
"github-runner-puppet",
|
|
"github-runner-signage",
|
|
"github-runner-dms",
|
|
"github-runner-telephony",
|
|
"github-runner-print-web",
|
|
"github-runner-chat",
|
|
"github-runner-mysql",
|
|
"github-runner-kiosk-linux",
|
|
"github-runner-updater",
|
|
};
|
|
|
|
private static readonly IReadOnlyDictionary<string, (string Deployment, string ProbePath)> BroaderHardeningDeployments =
|
|
new Dictionary<string, (string Deployment, string ProbePath)>(StringComparer.Ordinal)
|
|
{
|
|
["fc-aistation"] = ("aistation-web", "/healthz"),
|
|
["fc-chat"] = ("chat-web", "/healthz"),
|
|
["fc-devicemgmt"] = ("fc-devicemgmt-web", "/healthz"),
|
|
["fc-library"] = ("library-web", "/health"),
|
|
["fc-llm-bridge"] = ("fc-llm-bridge", "/healthz"),
|
|
["fc-messageboard"] = ("messageboard-web", "/health"),
|
|
["fc-retail"] = ("retail-web", "/healthz"),
|
|
["fc-ttsreader"] = ("ttsreader-web", "/health"),
|
|
["fc-updater"] = ("updatecenter-web", "/"),
|
|
["knowledge"] = ("knowledge-web", "/healthz"),
|
|
["telephony"] = ("telephony-web", "/health"),
|
|
["worldbuilder"] = ("worldbuilder-web", "/healthz"),
|
|
};
|
|
|
|
private static readonly HashSet<string> BroaderHardeningInternalPrestageApps = new(StringComparer.Ordinal)
|
|
{
|
|
"fc-aistation",
|
|
"fc-desktop",
|
|
"fc-dms",
|
|
"fc-library",
|
|
"fc-llm-bridge",
|
|
"fc-menuboard",
|
|
"fc-messageboard",
|
|
"fc-mysql",
|
|
"fc-php",
|
|
"fc-presentations",
|
|
"fc-retail",
|
|
"fc-scoreboard",
|
|
"fc-segmentdisplay",
|
|
"fc-signage",
|
|
"fc-ttsreader",
|
|
"knowledge",
|
|
"worldbuilder",
|
|
};
|
|
|
|
private static readonly IReadOnlyDictionary<string, string> WritableRunnerEnv = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["HOME"] = "/home/runner",
|
|
["DOTNET_INSTALL_DIR"] = "/home/runner/.dotnet",
|
|
["DOTNET_CLI_HOME"] = "/home/runner",
|
|
["NUGET_PACKAGES"] = "/home/runner/.nuget/packages",
|
|
["XDG_CACHE_HOME"] = "/home/runner/.cache",
|
|
["RUNNER_TOOL_CACHE"] = "/home/runner/_tool",
|
|
};
|
|
|
|
[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_MustRegisterRequiredReposAsRepoScopedDeployments()
|
|
{
|
|
var deployments = GitHubRunnerDeployments();
|
|
|
|
foreach (var expectedRunner in LinuxRunnerRepos)
|
|
{
|
|
deployments.Should().ContainKey(expectedRunner.Key);
|
|
|
|
var container = deployments[expectedRunner.Key].MainContainerMappings().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, "RUN_AS_ROOT").Should().Be("false");
|
|
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_MustSetWritableNonRootDotnetAndCachePaths()
|
|
{
|
|
foreach (var deployment in GitHubRunnerDeployments().Values)
|
|
{
|
|
var container = deployment.MainContainerMappings().Should().ContainSingle().Subject;
|
|
|
|
foreach (var expectedEnv in WritableRunnerEnv)
|
|
{
|
|
EnvValue(container, expectedEnv.Key).Should().Be(expectedEnv.Value, $"{deployment.Name} must keep .NET paths writable for uid 1001");
|
|
}
|
|
|
|
var mounts = ManifestNodeExtensions.MappingSequence(container, "volumeMounts")
|
|
.ToDictionary(
|
|
mount => ManifestNodeExtensions.Scalar(mount, "name") ?? string.Empty,
|
|
mount => ManifestNodeExtensions.Scalar(mount, "mountPath") ?? string.Empty,
|
|
StringComparer.Ordinal);
|
|
|
|
mounts.Should().Contain("runner-home", "/home/runner");
|
|
mounts.Should().Contain("nuget-cache", "/home/runner/.nuget/packages");
|
|
mounts.Should().Contain("tmp", "/tmp");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void GitHubRunnerFleet_MustAvoidRwoMultiAttachForRepoScopedDeployments()
|
|
{
|
|
var deployments = GitHubRunnerDeployments();
|
|
|
|
foreach (var deploymentName in RepoScopedLinuxRunnerDeployments)
|
|
{
|
|
var deployment = deployments[deploymentName];
|
|
// Sprint 34 ops trimmed runner load while the cluster was degraded
|
|
// to two healthy nodes. Repo-scoped runners can be tuned back above
|
|
// one replica, but they must stay RWO-safe before that happens.
|
|
ReplicaCount(deployment).Should().BeGreaterOrEqualTo(1, $"{deploymentName} must keep at least one repo-scoped runner online");
|
|
|
|
var volumes = deployment.MappingSequence("spec", "template", "spec", "volumes");
|
|
var claimNames = volumes
|
|
.Select(volume => ManifestNodeExtensions.Scalar(volume, "persistentVolumeClaim", "claimName"))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.ToList();
|
|
|
|
claimNames.Should().BeEmpty($"{deploymentName} must remain ready for safe multi-replica scaling without sharing a RWO PVC");
|
|
volumes.Should().Contain(volume =>
|
|
string.Equals(ManifestNodeExtensions.Scalar(volume, "name"), "nuget-cache", StringComparison.Ordinal)
|
|
&& ManifestNodeExtensions.Mapping(volume, "emptyDir") != null);
|
|
}
|
|
|
|
var common = deployments["github-runner"];
|
|
ReplicaCount(common).Should().Be(1);
|
|
common.MappingSequence("spec", "template", "spec", "volumes")
|
|
.Select(volume => ManifestNodeExtensions.Scalar(volume, "persistentVolumeClaim", "claimName"))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Should()
|
|
.ContainSingle()
|
|
.Which
|
|
.Should()
|
|
.Be("github-runner-nuget-cache");
|
|
}
|
|
|
|
[Fact]
|
|
public void Runners_MustNotPinToOperatorWorkstationHosts()
|
|
{
|
|
// CRITICAL SAFETY (operator directive 2026-05-26): BLUEJAY-WS is the
|
|
// operator's primary workstation — host of the 1Password Connect
|
|
// bearer token, fcadmin SSH keys to noc1, signing CA private keys,
|
|
// and source for every FC repo. A self-hosted GitHub Actions runner
|
|
// there would execute arbitrary PR code with that local access.
|
|
// Build-side analog of the Sprint 9 NEW safe-account exclusion gate
|
|
// (Puppet GPO/AppLocker/WDAC/audit-forwarder modules refuse to apply
|
|
// on BLUEJAY-WS). This lint asserts no GitHub-runner Deployment in
|
|
// apps/github-runner/ pins to a forbidden operator-workstation host
|
|
// via nodeName, nodeSelector, nodeAffinity, or tolerations.
|
|
// Existing legacy `bluejay-ws-sandbox-1` GitHub-registered runner is
|
|
// out of scope here (it's a runtime registration, not a K8s
|
|
// Deployment) — see CLAUDE.md "Common Mistakes" entry and
|
|
// feedback_bluejay_ws_never_public_runner.md.
|
|
var forbiddenHostPatterns = new[]
|
|
{
|
|
"bluejay-ws",
|
|
"BLUEJAY-WS",
|
|
"bluejay-ws.iamworkin.lan",
|
|
"iamworkin-ws",
|
|
};
|
|
|
|
bool ContainsForbidden(string? value) =>
|
|
!string.IsNullOrWhiteSpace(value)
|
|
&& forbiddenHostPatterns.Any(pattern => value!.Contains(pattern, StringComparison.OrdinalIgnoreCase));
|
|
|
|
var violations = GitHubRunnerDeployments().Values.SelectMany(deployment =>
|
|
{
|
|
var local = new List<string>();
|
|
var podSpec = ManifestNodeExtensions.Mapping(deployment.Root, "spec", "template", "spec");
|
|
if (podSpec is null)
|
|
{
|
|
return local;
|
|
}
|
|
|
|
// nodeName: pins the pod to a specific node by name.
|
|
var nodeName = ManifestNodeExtensions.Scalar(podSpec, "nodeName");
|
|
if (ContainsForbidden(nodeName))
|
|
{
|
|
local.Add($"{deployment.Name} sets nodeName='{nodeName}' which targets a forbidden operator-workstation host.");
|
|
}
|
|
|
|
// nodeSelector: dict of label → value pinning the pod to nodes
|
|
// carrying matching labels. Examples that would trip this:
|
|
// kubernetes.io/hostname: bluejay-ws
|
|
// flowercore.io/host: bluejay-ws.iamworkin.lan
|
|
var nodeSelector = ManifestNodeExtensions.Mapping(podSpec, "nodeSelector");
|
|
if (nodeSelector is not null)
|
|
{
|
|
foreach (var entry in nodeSelector.Children)
|
|
{
|
|
var key = entry.Key is YamlScalarNode keyScalar ? keyScalar.Value : null;
|
|
var value = entry.Value is YamlScalarNode valueScalar ? valueScalar.Value : null;
|
|
if (ContainsForbidden(value))
|
|
{
|
|
local.Add($"{deployment.Name} has nodeSelector entry '{key}: {value}' which targets a forbidden operator-workstation host.");
|
|
}
|
|
}
|
|
}
|
|
|
|
// nodeAffinity: matchExpressions over node labels.
|
|
foreach (var term in ManifestNodeExtensions.MappingSequence(podSpec, "affinity", "nodeAffinity", "requiredDuringSchedulingIgnoredDuringExecution", "nodeSelectorTerms"))
|
|
{
|
|
foreach (var expr in ManifestNodeExtensions.MappingSequence(term, "matchExpressions"))
|
|
{
|
|
var key = ManifestNodeExtensions.Scalar(expr, "key");
|
|
foreach (var valueNode in ManifestNodeExtensions.ScalarSequence(expr, "values"))
|
|
{
|
|
if (ContainsForbidden(valueNode))
|
|
{
|
|
local.Add($"{deployment.Name} has nodeAffinity matchExpression '{key}' value '{valueNode}' which targets a forbidden operator-workstation host.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// tolerations: scheduling onto a tainted operator-workstation
|
|
// node would let the runner run there. Forbid any toleration
|
|
// value that names the workstation.
|
|
foreach (var toleration in ManifestNodeExtensions.MappingSequence(podSpec, "tolerations"))
|
|
{
|
|
var key = ManifestNodeExtensions.Scalar(toleration, "key");
|
|
var value = ManifestNodeExtensions.Scalar(toleration, "value");
|
|
if (ContainsForbidden(key))
|
|
{
|
|
local.Add($"{deployment.Name} has toleration key '{key}' which targets a forbidden operator-workstation host.");
|
|
}
|
|
if (ContainsForbidden(value))
|
|
{
|
|
local.Add($"{deployment.Name} has toleration value '{value}' which targets a forbidden operator-workstation host.");
|
|
}
|
|
}
|
|
|
|
return local;
|
|
}).ToList();
|
|
|
|
violations.Should().BeEmpty("BLUEJAY-WS / iamworkin-ws must never host a fleet GitHub Actions runner; see CLAUDE.md 'Registering BLUEJAY-WS as a fleet GitHub Actions runner' and feedback_bluejay_ws_never_public_runner.md");
|
|
}
|
|
|
|
[Fact]
|
|
public void Monitoring_MustAlertWhenLinuxRunnerDeploymentIsUnavailable()
|
|
{
|
|
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
|
|
|
|
monitoring.Should().Contain("MacMiniRunnerOffline");
|
|
monitoring.Should().Contain("LinuxRunnerOffline");
|
|
monitoring.Should().Contain("kube_deployment_status_replicas_ready");
|
|
monitoring.Should().Contain("github-runner(|-(sharedpos|puppet|signage|dms|telephony|print-web|chat|mysql|kiosk-linux))");
|
|
monitoring.Should().Contain("folder: CI Alerts");
|
|
monitoring.Should().Contain("uid: linux-runner-offline");
|
|
monitoring.Should().Contain("alert_channel: irc");
|
|
}
|
|
|
|
[Fact]
|
|
public void Monitoring_GenericKubernetesAlerts_MustExcludeEphemeralGithubRunnerNamespace()
|
|
{
|
|
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
|
|
|
|
monitoring.Should().Contain("kube_pod_container_status_restarts_total{namespace!=\"github-runner\"}");
|
|
monitoring.Should().Contain("and on(namespace, pod) kube_pod_info");
|
|
monitoring.Should().Contain("kube_deployment_spec_replicas{namespace!=\"github-runner\"} != kube_deployment_status_replicas_available{namespace!=\"github-runner\"}");
|
|
monitoring.Should().Contain("dedicated LinuxRunnerOffline/MacMiniRunnerOffline alerts");
|
|
}
|
|
|
|
[Fact]
|
|
public void Monitoring_BlackboxTargetsForOidcSensitiveServices_MustUseAnonymousHealthRoutesWhenAvailable()
|
|
{
|
|
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
|
|
|
|
monitoring.Should().Contain("https://chat.iamworkin.lan/healthz");
|
|
monitoring.Should().Contain("https://dist.iamworkin.lan/healthz");
|
|
monitoring.Should().Contain("https://dms.iamworkin.lan/healthz");
|
|
monitoring.Should().Contain("https://print.iamworkin.lan/healthz");
|
|
monitoring.Should().Contain("https://knowledge.iamworkin.lan/healthz");
|
|
monitoring.Should().Contain("https://library.iamworkin.lan/health");
|
|
monitoring.Should().Contain("https://aistation.iamworkin.lan/healthz");
|
|
monitoring.Should().NotContain("https://print.iamworkin.lan/\"");
|
|
}
|
|
|
|
[Fact]
|
|
public void OidcEnforcedDeployments_WithHttpHealthzProbes_MustDeclareAnonymousHealthzContract()
|
|
{
|
|
var violations = Inventory.Documents
|
|
.Where(document => document.Kind == "Deployment")
|
|
.SelectMany(document => document.MainContainerMappings()
|
|
.Where(container => string.Equals(EnvValue(container, "FlowerCore__Auth__Enabled"), "true", StringComparison.OrdinalIgnoreCase))
|
|
.Where(container => string.Equals(EnvValue(container, "FlowerCore__Auth__Oidc__Enabled"), "true", StringComparison.OrdinalIgnoreCase))
|
|
.Where(container => ProbeHttpGetPath(container, "readinessProbe") == "/healthz"
|
|
|| ProbeHttpGetPath(container, "startupProbe") == "/healthz")
|
|
.Where(_ => !string.Equals(
|
|
PodAnnotation(document, "flowercore.io/healthz-auth-policy"),
|
|
"allow-anonymous",
|
|
StringComparison.Ordinal))
|
|
.Select(container =>
|
|
{
|
|
var containerName = ManifestNodeExtensions.Scalar(container, "name") ?? "<unnamed>";
|
|
return $"{document.Descriptor} container '{containerName}' enforces OIDC while probing /healthz but lacks flowercore.io/healthz-auth-policy: allow-anonymous.";
|
|
}))
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Knowledge_OidcEnforcement_MustKeepHealthzAnonymousContractVisibleInManifest()
|
|
{
|
|
var knowledge = Inventory.Documents
|
|
.Single(document => document.Kind == "Deployment" && document.Namespace == "knowledge" && document.Name == "knowledge-web");
|
|
var container = knowledge.MainContainerMappings().Should().ContainSingle().Subject;
|
|
|
|
EnvValue(container, "FlowerCore__Auth__Enabled").Should().Be("true");
|
|
EnvValue(container, "FlowerCore__Auth__Oidc__Enabled").Should().Be("true");
|
|
ProbeHttpGetPath(container, "readinessProbe").Should().Be("/healthz");
|
|
PodAnnotation(knowledge, "flowercore.io/healthz-auth-policy").Should().Be("allow-anonymous");
|
|
}
|
|
|
|
[Fact]
|
|
public void Distribution_OidcEnforcement_MustKeepHealthzAnonymousContractVisibleInManifest()
|
|
{
|
|
var distribution = Inventory.Documents
|
|
.Single(document => document.Kind == "Deployment" && document.Namespace == "fc-distribution" && document.Name == "fc-distribution");
|
|
var container = distribution.MainContainerMappings().Should().ContainSingle().Subject;
|
|
|
|
EnvValue(container, "FlowerCore__Auth__Oidc__Enabled").Should().Be("true");
|
|
EnvValue(container, "FlowerCore__Auth__Enabled").Should().Be("true");
|
|
ProbeHttpGetPath(container, "readinessProbe").Should().Be("/healthz");
|
|
PodAnnotation(distribution, "flowercore.io/healthz-auth-policy").Should().Be("allow-anonymous");
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
[Fact]
|
|
public void FcDeviceManagement_MustShipExpectedManifestSet()
|
|
{
|
|
var appRoot = Path.Combine(Inventory.BluejayRoot, "apps", "fc-devicemgmt");
|
|
Directory.Exists(appRoot).Should().BeTrue("Sprint 8 Cx-5 owns apps/fc-devicemgmt.");
|
|
|
|
var expectedFiles = new[]
|
|
{
|
|
"1password-item.yaml",
|
|
"certificate-web.yaml",
|
|
"clusterrole-operator.yaml",
|
|
"clusterrolebinding-operator.yaml",
|
|
"deployment-operator.yaml",
|
|
"deployment-web.yaml",
|
|
"ingressroute-web.yaml",
|
|
"namespace.yaml",
|
|
"network-policy.yaml",
|
|
"service-web.yaml",
|
|
"serviceaccount-operator.yaml",
|
|
};
|
|
|
|
Directory.GetFiles(appRoot, "*.yaml")
|
|
.Select(Path.GetFileName)
|
|
.Should()
|
|
.BeEquivalentTo(expectedFiles);
|
|
|
|
foreach (var expectedFile in expectedFiles)
|
|
{
|
|
FcDeviceManagementDocuments()
|
|
.Should()
|
|
.Contain(document => document.RelativePath == $"fc-devicemgmt/{expectedFile}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void FcDeviceManagement_ObjectsMustCarryStandardTraceabilityLabels()
|
|
{
|
|
var requiredLabels = new[]
|
|
{
|
|
"app.kubernetes.io/name",
|
|
"app.kubernetes.io/part-of",
|
|
"app.kubernetes.io/managed-by",
|
|
"flowercore.io/tenant-id",
|
|
"flowercore.io/created-by",
|
|
};
|
|
|
|
var violations = FcDeviceManagementDocuments()
|
|
.SelectMany(document => requiredLabels
|
|
.Where(label => string.IsNullOrWhiteSpace(document.Scalar("metadata", "labels", label)))
|
|
.Select(label => $"{document.Descriptor} is missing metadata.labels['{label}']."))
|
|
.Concat(FcDeviceManagementDocuments()
|
|
.Where(document => document.Kind == "Deployment")
|
|
.SelectMany(document => requiredLabels
|
|
.Where(label => string.IsNullOrWhiteSpace(document.Scalar("spec", "template", "metadata", "labels", label)))
|
|
.Select(label => $"{document.Descriptor} pod template is missing metadata.labels['{label}'].")))
|
|
.Concat(FcDeviceManagementDocuments()
|
|
.Where(document => document.Kind == "Deployment")
|
|
.Where(document => string.IsNullOrWhiteSpace(document.Scalar("spec", "template", "metadata", "annotations", "flowercore.io/audit-trace-id")))
|
|
.Select(document => $"{document.Descriptor} pod template is missing flowercore.io/audit-trace-id."))
|
|
.ToList();
|
|
|
|
violations.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void FcDeviceManagement_IngressMustUseCertManagerAndKeepPublicHostDisabled()
|
|
{
|
|
var appText = string.Join(
|
|
Environment.NewLine,
|
|
Directory.GetFiles(Path.Combine(Inventory.BluejayRoot, "apps", "fc-devicemgmt"), "*.yaml")
|
|
.Select(File.ReadAllText));
|
|
|
|
appText.Should().NotContain("certResolver");
|
|
appText.Should().Contain("update.flowercore.io");
|
|
appText.Should().Contain("disabled-until-Q-OIDC-1");
|
|
|
|
FcDeviceManagementDocuments()
|
|
.Where(document => document.Kind == "IngressRoute")
|
|
.SelectMany(document => document.MappingSequence("spec", "routes"))
|
|
.Select(route => ManifestNodeExtensions.Scalar(route, "match") ?? string.Empty)
|
|
.Should()
|
|
.Contain(match => match.Contains("Host(`devices.iamworkin.lan`)", StringComparison.Ordinal))
|
|
.And.NotContain(match => match.Contains("Host(`update.flowercore.io`)", StringComparison.Ordinal));
|
|
|
|
var certificate = FcDeviceManagementDocuments()
|
|
.Single(document => document.Kind == "Certificate" && document.Name == "fc-devicemgmt-web-tls");
|
|
|
|
certificate.Scalar("spec", "issuerRef", "name").Should().Be("step-ca-acme");
|
|
certificate.Scalar("spec", "issuerRef", "kind").Should().Be("ClusterIssuer");
|
|
ManifestNodeExtensions.ScalarSequence(certificate.Root, "spec", "dnsNames")
|
|
.Should()
|
|
.ContainSingle("devices.iamworkin.lan");
|
|
}
|
|
|
|
[Fact]
|
|
public void FcDeviceManagement_OperatorRbacMustCoverDevicesAndOwnerLookup()
|
|
{
|
|
var clusterRole = FcDeviceManagementDocuments()
|
|
.Single(document => document.Kind == "ClusterRole" && document.Name == "fc-devicemgmt-operator");
|
|
var allScalars = clusterRole.AllScalars().ToList();
|
|
|
|
allScalars.Should().Contain("devices.flowercore.io");
|
|
allScalars.Should().Contain("*");
|
|
allScalars.Should().Contain("deployments");
|
|
allScalars.Should().Contain("get");
|
|
|
|
var operatorDeployment = FcDeviceManagementDocuments()
|
|
.Single(document => document.Kind == "Deployment" && document.Name == "fc-devicemgmt-operator");
|
|
|
|
operatorDeployment.AllScalars().Should().Contain("FLOWERCORE_KUBERNETES_OWNER_DEPLOYMENT");
|
|
operatorDeployment.AllScalars().Should().Contain("fc-devicemgmt-operator");
|
|
}
|
|
|
|
[Fact]
|
|
public void FcDeviceManagement_RuntimeSecretsMustUseOnePasswordItemPattern()
|
|
{
|
|
var item = FcDeviceManagementDocuments()
|
|
.Single(document => document.Kind == "OnePasswordItem" && document.Name == "fc-devicemgmt-runtime");
|
|
|
|
item.Scalar("spec", "itemPath")
|
|
.Should()
|
|
.Be("vaults/IAmWorkin/items/FlowerCore DeviceManagement Runtime");
|
|
|
|
var appText = string.Join(
|
|
Environment.NewLine,
|
|
Directory.GetFiles(Path.Combine(Inventory.BluejayRoot, "apps", "fc-devicemgmt"), "*.yaml")
|
|
.Select(File.ReadAllText));
|
|
|
|
FcDeviceManagementDocuments().Should().NotContain(document => document.Kind == "Secret");
|
|
appText.Should().Contain("secretKeyRef:");
|
|
appText.Should().Contain("secretName: fc-devicemgmt-runtime");
|
|
appText.Should().NotContain("stringData:");
|
|
appText.Should().NotContain("from-literal");
|
|
appText.Should().NotContain("tls.key:");
|
|
}
|
|
|
|
[Fact]
|
|
public void FcDeviceManagement_NetworkPoliciesMustAllowLanAgentsSynologyAndDnatPorts()
|
|
{
|
|
var policies = FcDeviceManagementDocuments()
|
|
.Where(document => document.Kind == "NetworkPolicy")
|
|
.ToList();
|
|
|
|
policies.Should().HaveCount(2);
|
|
|
|
var combinedScalars = policies.SelectMany(policy => policy.AllScalars()).ToList();
|
|
combinedScalars.Should().Contain("10.0.56.0/24");
|
|
combinedScalars.Should().Contain("10.0.57.0/24");
|
|
combinedScalars.Should().Contain("10.0.58.0/24");
|
|
combinedScalars.Should().Contain("10.0.68.0/27");
|
|
combinedScalars.Should().Contain("10.0.58.3/32");
|
|
|
|
var combinedEgressPorts = policies.SelectMany(policy => policy.EgressPorts()).ToHashSet(StringComparer.Ordinal);
|
|
combinedEgressPorts.Should().Contain(new[] { "80", "443", "8080", "8443", "2049", "111" });
|
|
|
|
var traefikVipPolicies = policies
|
|
.Where(policy => policy.AllScalars().Any(value => value.Contains("10.0.56.200", StringComparison.Ordinal)))
|
|
.ToList();
|
|
|
|
traefikVipPolicies.Should().ContainSingle();
|
|
traefikVipPolicies[0].EgressPorts().Should().Contain(new[] { "80", "443", "8080", "8443" });
|
|
}
|
|
|
|
[Fact]
|
|
public void FcDeviceManagement_MustRelyOnApplicationSetDiscovery()
|
|
{
|
|
var documents = FcDeviceManagementDocuments();
|
|
|
|
documents.Should().NotContain(document => document.Kind == "Application");
|
|
|
|
var ns = documents.Single(document => document.Kind == "Namespace" && document.Name == "fc-devicemgmt");
|
|
ns.FileText.Should().Contain("ArgoCD discovers this directory as Application `infra-fc-devicemgmt`.");
|
|
}
|
|
|
|
[Fact]
|
|
public void BroaderHardeningDeployments_MustAnnotateAnonymousHealthProbeIntent()
|
|
{
|
|
foreach (var expected in BroaderHardeningDeployments)
|
|
{
|
|
var deployment = AppDocuments(expected.Key)
|
|
.Single(document => document.Kind == "Deployment" && document.Name == expected.Value.Deployment);
|
|
|
|
PodAnnotation(deployment, "fc.flowercore.io/healthz-anon").Should().Be("true");
|
|
PodAnnotation(deployment, "fc.flowercore.io/probe-path").Should().Be(expected.Value.ProbePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BroaderHardeningDeployments_MustDocumentForwardedProtoAuthPosture()
|
|
{
|
|
foreach (var expected in BroaderHardeningDeployments)
|
|
{
|
|
var deployment = AppDocuments(expected.Key)
|
|
.Single(document => document.Kind == "Deployment" && document.Name == expected.Value.Deployment);
|
|
|
|
deployment.FileText.Should().Contain(
|
|
"fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178)");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void BroaderHardeningInternalApps_MustOnlyPrestageCommentedPublicMethodAllowlist()
|
|
{
|
|
foreach (var app in BroaderHardeningInternalPrestageApps)
|
|
{
|
|
var documents = AppDocuments(app);
|
|
var text = string.Join(Environment.NewLine, documents.Select(document => document.FileText));
|
|
|
|
text.Should().Contain("PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only)");
|
|
text.Should().Contain("# - match: Host(`");
|
|
text.Should().Contain("Method(`GET`) || Method(`HEAD`)");
|
|
|
|
documents
|
|
.Where(document => document.Kind == "IngressRoute")
|
|
.SelectMany(document => document.MappingSequence("spec", "routes"))
|
|
.Select(route => ManifestNodeExtensions.Scalar(route, "match") ?? string.Empty)
|
|
.Should()
|
|
.NotContain(match => match.Contains(".flowercore.io", StringComparison.Ordinal),
|
|
"Sprint 61 broader hardening only pre-stages commented public hosts for internal-only apps");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void OidcFlipServices_AreGitOpsManagedWithHealthzProbes()
|
|
{
|
|
var deployments = new[]
|
|
{
|
|
(App: "fc-dns", Name: "dns-web", Slug: "dns", Secret: "dns-oidc-client"),
|
|
(App: "fc-media", Name: "fc-media-web", Slug: "media", Secret: "media-oidc-client"),
|
|
(App: "fc-distribution", Name: "fc-distribution", Slug: "distribution", Secret: "distribution-oidc-client"),
|
|
};
|
|
|
|
foreach (var expected in deployments)
|
|
{
|
|
var deployment = AppDocuments(expected.App)
|
|
.Single(document => document.Kind == "Deployment" && document.Name == expected.Name);
|
|
var container = deployment.MainContainerMappings().Should().ContainSingle().Subject;
|
|
|
|
EnvValue(container, "FlowerCore__Auth__Enabled").Should().Be("true");
|
|
EnvValue(container, "FlowerCore__Auth__Oidc__Enabled").Should().Be("true");
|
|
(EnvValue(container, "FlowerCore__Auth__Oidc__Audience") ?? EnvValue(container, "FlowerCore__Auth__Oidc__ClientId"))
|
|
.Should()
|
|
.Be(expected.Slug);
|
|
EnvSecretName(container, "FlowerCore__Auth__Oidc__ClientSecret").Should().Be(expected.Secret);
|
|
EnvSecretOptional(container, "FlowerCore__Auth__Oidc__ClientSecret").Should().Be("true");
|
|
|
|
ProbePath(container, "readinessProbe").Should().Be("/healthz");
|
|
if (ProbePath(container, "startupProbe") is { } startupProbePath)
|
|
{
|
|
startupProbePath.Should().Be("/healthz");
|
|
}
|
|
|
|
if (ProbePath(container, "livenessProbe") is { } livenessProbePath)
|
|
{
|
|
livenessProbePath.Should().Be("/healthz");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void OidcFlipServices_UseOnePasswordItemClientSecrets()
|
|
{
|
|
var expectedItems = new Dictionary<string, (string Name, string ItemPath)>(StringComparer.Ordinal)
|
|
{
|
|
["fc-dns"] = ("dns-oidc-client", "vaults/IAmWorkin/items/dns-oidc-client"),
|
|
["fc-media"] = ("media-oidc-client", "vaults/IAmWorkin/items/media-oidc-client"),
|
|
["fc-distribution"] = ("distribution-oidc-client", "vaults/IAmWorkin/items/distribution-oidc-client"),
|
|
};
|
|
|
|
foreach (var expected in expectedItems)
|
|
{
|
|
var item = AppDocuments(expected.Key)
|
|
.Single(document => document.Kind == "OnePasswordItem" && document.Name == expected.Value.Name);
|
|
|
|
item.Scalar("spec", "itemPath").Should().Be(expected.Value.ItemPath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DnsAndMediaGitOpsAdoption_PreservesLiveStorageAndImageShape()
|
|
{
|
|
var dnsDeployment = AppDocuments("fc-dns")
|
|
.Single(document => document.Kind == "Deployment" && document.Name == "dns-web");
|
|
var dnsContainer = dnsDeployment.MainContainerMappings().Should().ContainSingle().Subject;
|
|
var dnsPvc = AppDocuments("fc-dns")
|
|
.Single(document => document.Kind == "PersistentVolumeClaim" && document.Name == "dns-web-data");
|
|
|
|
ManifestNodeExtensions.Scalar(dnsContainer, "image").Should().Be("localhost/fc-dns-web:v20260604-oidc-proper");
|
|
dnsPvc.Scalar("spec", "storageClassName").Should().Be("longhorn");
|
|
dnsPvc.Scalar("spec", "resources", "requests", "storage").Should().Be("1Gi");
|
|
|
|
var mediaDeployment = AppDocuments("fc-media")
|
|
.Single(document => document.Kind == "Deployment" && document.Name == "fc-media-web");
|
|
var mediaContainer = mediaDeployment.MainContainerMappings().Should().ContainSingle().Subject;
|
|
var mediaPvc = AppDocuments("fc-media")
|
|
.Single(document => document.Kind == "PersistentVolumeClaim" && document.Name == "fc-media-data");
|
|
|
|
ManifestNodeExtensions.Scalar(mediaContainer, "image").Should().Be("localhost/fc-media-web:v20260604-oidc-proper");
|
|
mediaPvc.Scalar("spec", "storageClassName").Should().Be("longhorn");
|
|
mediaPvc.Scalar("spec", "resources", "requests", "storage").Should().Be("20Gi");
|
|
|
|
mediaDeployment.AllScalars().Should().Contain(new[]
|
|
{
|
|
"/volume1/kubernetes/fc-media-transcodes",
|
|
"/volume1/kubernetes/fc-media-inbox",
|
|
"/volume1/video",
|
|
});
|
|
|
|
var distributionDeployment = AppDocuments("fc-distribution")
|
|
.Single(document => document.Kind == "Deployment" && document.Name == "fc-distribution");
|
|
var distributionContainer = distributionDeployment.MainContainerMappings().Should().ContainSingle().Subject;
|
|
|
|
ManifestNodeExtensions.Scalar(distributionContainer, "image").Should().Be("localhost/fc-distribution:v20260604-oidc-root-anon");
|
|
}
|
|
|
|
[Fact]
|
|
public void MonitoringProbes_UseHealthzForOidcGatedHosts()
|
|
{
|
|
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
|
|
|
|
monitoring.Should().Contain("\"https://dns.iamworkin.lan/healthz\"");
|
|
monitoring.Should().Contain("\"https://dist.iamworkin.lan/healthz\"");
|
|
monitoring.Should().Contain("\"https://media.iamworkin.lan/healthz\"");
|
|
monitoring.Should().NotContain("\"https://dns.iamworkin.lan/\"");
|
|
monitoring.Should().NotContain("\"https://dist.iamworkin.lan/\"");
|
|
monitoring.Should().NotContain("\"https://media.iamworkin.lan/\"");
|
|
}
|
|
|
|
[Fact]
|
|
public void DistributionPublicIngress_KeepsGetHeadMethodAllowlist()
|
|
{
|
|
var publicIngress = AppDocuments("fc-distribution")
|
|
.Single(document => document.Kind == "IngressRoute" && document.Name == "fc-distribution-public");
|
|
var route = publicIngress.MappingSequence("spec", "routes").Should().ContainSingle().Subject;
|
|
var match = ManifestNodeExtensions.Scalar(route, "match");
|
|
|
|
match.Should().Contain("Host(`dist.flowercore.io`)");
|
|
match.Should().Contain("Method(`GET`)");
|
|
match.Should().Contain("Method(`HEAD`)");
|
|
match.Should().NotContain("Method(`POST`)");
|
|
}
|
|
|
|
[Fact]
|
|
public void DnsAndMediaIngressRoutes_MatchLiveInternalHosts()
|
|
{
|
|
var dnsRoute = AppDocuments("fc-dns")
|
|
.Single(document => document.Kind == "IngressRoute" && document.Name == "dns-web")
|
|
.MappingSequence("spec", "routes")
|
|
.Should()
|
|
.ContainSingle()
|
|
.Subject;
|
|
var mediaRoute = AppDocuments("fc-media")
|
|
.Single(document => document.Kind == "IngressRoute" && document.Name == "fc-media-web")
|
|
.MappingSequence("spec", "routes")
|
|
.Should()
|
|
.ContainSingle()
|
|
.Subject;
|
|
|
|
ManifestNodeExtensions.Scalar(dnsRoute, "match").Should().Be("Host(`dns.iamworkin.lan`)");
|
|
ManifestNodeExtensions.Scalar(mediaRoute, "match").Should().Be("Host(`media.iamworkin.lan`)");
|
|
}
|
|
|
|
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 IReadOnlyDictionary<string, ManifestDocument> GitHubRunnerDeployments()
|
|
{
|
|
return Inventory.Documents
|
|
.Where(document => document.Kind == "Deployment")
|
|
.Where(document => document.Namespace == "github-runner")
|
|
.ToDictionary(document => document.Name, StringComparer.Ordinal);
|
|
}
|
|
|
|
private static int ReplicaCount(ManifestDocument document)
|
|
{
|
|
return int.TryParse(document.Scalar("spec", "replicas"), out var replicas) ? replicas : 1;
|
|
}
|
|
|
|
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 string? EnvSecretOptional(YamlMappingNode container, string name)
|
|
{
|
|
return EnvMapping(container, name) is { } env
|
|
? ManifestNodeExtensions.Scalar(env, "valueFrom", "secretKeyRef", "optional")
|
|
: null;
|
|
}
|
|
|
|
private static string? ProbePath(YamlMappingNode container, string probeKey)
|
|
{
|
|
return ManifestNodeExtensions.Scalar(container, probeKey, "httpGet", "path");
|
|
}
|
|
|
|
private static IReadOnlyList<ManifestDocument> AppDocuments(string app)
|
|
{
|
|
return Inventory.Documents
|
|
.Where(document => document.RelativePath.StartsWith($"{app}/", StringComparison.Ordinal))
|
|
.ToList();
|
|
}
|
|
|
|
private static YamlMappingNode? EnvMapping(YamlMappingNode container, string name)
|
|
{
|
|
return ManifestNodeExtensions.MappingSequence(container, "env")
|
|
.SingleOrDefault(env => string.Equals(ManifestNodeExtensions.Scalar(env, "name"), name, StringComparison.Ordinal));
|
|
}
|
|
|
|
private static string? PodAnnotation(ManifestDocument document, string name)
|
|
{
|
|
return document.Scalar("spec", "template", "metadata", "annotations", name);
|
|
}
|
|
|
|
private static string? ProbeHttpGetPath(YamlMappingNode container, string probeKey)
|
|
{
|
|
return ManifestNodeExtensions.TryGetMapping(container, probeKey, out var probe)
|
|
&& ManifestNodeExtensions.TryGetMapping(probe, "httpGet", out var httpGet)
|
|
? ManifestNodeExtensions.Scalar(httpGet, "path")
|
|
: null;
|
|
}
|
|
|
|
private static IReadOnlyList<ManifestDocument> FcDeviceManagementDocuments()
|
|
{
|
|
return Inventory.Documents
|
|
.Where(document => document.RelativePath.StartsWith("fc-devicemgmt/", StringComparison.Ordinal))
|
|
.ToList();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// MainContainerMappings excludes initContainers. Use this when asserting
|
|
// properties of the primary container (env, image, volumeMounts) where an
|
|
// initContainer would be a false-positive match — e.g. the GitHub runner
|
|
// image's `setup-runner-home` initContainer should not count toward the
|
|
// single-container assertions on the runner deployments.
|
|
public IReadOnlyList<YamlMappingNode> MainContainerMappings()
|
|
{
|
|
var podSpec = PodSpec();
|
|
if (podSpec is null)
|
|
{
|
|
return Array.Empty<YamlMappingNode>();
|
|
}
|
|
|
|
return ManifestNodeExtensions.MappingSequence(podSpec, "containers").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;
|
|
}
|
|
}
|