Sprint 8 IMPL lane Cx-5: fc-devicemgmt K8s manifests (rebased onto main 2026-05-18; 13 files, +944).
Namespace + Web Deployment (replicas:2, MySQL backend) + Operator Deployment (replicas:1, KubeOps leader-elect) + Service + Certificate (step-ca-acme ClusterIssuer) + Traefik IngressRoute (devices.iamworkin.lan internal) + ServiceAccount + ClusterRole + ClusterRoleBinding + NetworkPolicy (CNI DNAT-aware backend ports) + OnePasswordItem (5-field consolidated) + ArgoCD Application bootstrap shape + lint coverage.
Follow-ups (not merge blockers):
- localhost/fc-devicemgmt-{web,operator}:v20260512-cx5 must be imported to all 3 RKE2 nodes; pods will ErrImageNeverPull until imported.
- 1Password vault item 'FlowerCore DeviceManagement Runtime' must be created with 5 fields before pods can start.
- DNS devices.iamworkin.lan -> 10.0.56.200 already present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
992 lines
40 KiB
C#
992 lines
40 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> 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",
|
|
};
|
|
|
|
private static readonly HashSet<string> ScaledLinuxRunnerDeployments = 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",
|
|
};
|
|
|
|
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].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, "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.ContainerMappings().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_MustAvoidRwoMultiAttachForScaledDeployments()
|
|
{
|
|
var deployments = GitHubRunnerDeployments();
|
|
|
|
foreach (var deploymentName in ScaledLinuxRunnerDeployments)
|
|
{
|
|
var deployment = deployments[deploymentName];
|
|
ReplicaCount(deployment).Should().Be(2);
|
|
|
|
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} is scaled and must not share 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 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 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",
|
|
"argocd-application.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_ArgocdApplicationMustMatchApplicationSetDiscoveryConventions()
|
|
{
|
|
var application = FcDeviceManagementDocuments()
|
|
.Single(document => document.Kind == "Application" && document.Name == "infra-fc-devicemgmt");
|
|
|
|
application.Namespace.Should().Be("argocd");
|
|
application.Scalar("spec", "source", "repoURL")
|
|
.Should()
|
|
.Be("http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git");
|
|
application.Scalar("spec", "source", "path").Should().Be("apps/fc-devicemgmt");
|
|
application.Scalar("spec", "destination", "namespace").Should().Be("fc-devicemgmt");
|
|
}
|
|
|
|
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 YamlMappingNode? EnvMapping(YamlMappingNode container, string name)
|
|
{
|
|
return ManifestNodeExtensions.MappingSequence(container, "env")
|
|
.SingleOrDefault(env => string.Equals(ManifestNodeExtensions.Scalar(env, "name"), name, StringComparison.Ordinal));
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|