diff --git a/apps/fc-desktop/limitrange.yaml b/apps/fc-desktop/limitrange.yaml new file mode 100644 index 0000000..7572b28 --- /dev/null +++ b/apps/fc-desktop/limitrange.yaml @@ -0,0 +1,33 @@ +# FlowerCore Remote Desktop - session pod resource defaults +# +# Namespace-level LimitRange for Sprint 44 Phase 1. This defends the +# fc-desktop namespace from unbounded container requests while the +# per-tenant advisory FairShareEvaluator lands in FlowerCore.RemoteDesktop. +apiVersion: v1 +kind: LimitRange +metadata: + name: fc-desktop-pod-defaults + namespace: fc-desktop + labels: + app.kubernetes.io/name: fc-desktop + app.kubernetes.io/part-of: remotedesktop + app.kubernetes.io/component: capacity-guard + app.kubernetes.io/managed-by: argocd + flowercore.io/owner: infra + annotations: + flowercore.io/phase: sprint-44-cx-9-phase-a +spec: + limits: + - type: Container + default: + cpu: "1.0" + memory: "2Gi" + defaultRequest: + cpu: "500m" + memory: "1Gi" + max: + cpu: "2.0" + memory: "4Gi" + min: + cpu: "100m" + memory: "128Mi" diff --git a/apps/fc-desktop/resourcequota.yaml b/apps/fc-desktop/resourcequota.yaml new file mode 100644 index 0000000..ab61273 --- /dev/null +++ b/apps/fc-desktop/resourcequota.yaml @@ -0,0 +1,36 @@ +# FlowerCore Remote Desktop - namespace ResourceQuota (GitOps-managed) +# +# Adopts the live fc-desktop-session-cap object created during the +# 2026-05-19 prewarm-cascade triage. Sprint 44 Phase 1 keeps the pod, +# CPU, and memory guard unchanged, then adds storage/PVC backstops from +# the fc-desktop CPU expansion substrate. +# +# Two-phase deploy note: +# Phase A: apply this ResourceQuota and limitrange.yaml with the current +# FlowerCore.RemoteDesktop image. +# Phase B: bump the service image only after the RemoteDesktop service +# admission/fair-share code lands in that repo. +apiVersion: v1 +kind: ResourceQuota +metadata: + name: fc-desktop-session-cap + namespace: fc-desktop + labels: + app.kubernetes.io/name: fc-desktop + app.kubernetes.io/part-of: remotedesktop + app.kubernetes.io/component: capacity-guard + app.kubernetes.io/managed-by: argocd + flowercore.io/owner: infra + annotations: + flowercore.io/rationale: | + Operator-requested limit 2026-05-19: cluster CPU exhausted by RD + pool prewarm cascade. Preserve count/pods=15 plus requests.cpu=8 + and requests.memory=16Gi until capacity expansion lands. + flowercore.io/phase: sprint-44-cx-9-phase-a +spec: + hard: + count/pods: "15" + requests.cpu: "8" + requests.memory: "16Gi" + requests.storage: "500Gi" + persistentvolumeclaims: "30" diff --git a/tests/bluejay-infra-lint/FcDesktopCapacityPolicyTests.cs b/tests/bluejay-infra-lint/FcDesktopCapacityPolicyTests.cs new file mode 100644 index 0000000..f3100f2 --- /dev/null +++ b/tests/bluejay-infra-lint/FcDesktopCapacityPolicyTests.cs @@ -0,0 +1,285 @@ +using FluentAssertions; +using YamlDotNet.RepresentationModel; +using Xunit; + +namespace BluejayInfraLint.Tests; + +[Trait("Category", "Unit")] +public sealed class FcDesktopCapacityPolicyTests +{ + private static readonly ManifestInventory Inventory = ManifestInventory.Load(); + + [Fact] + public void FcDesktop_AppDirectoryMustExist() + { + Directory.Exists(Path.Combine(Inventory.BluejayRoot, "apps", "fc-desktop")) + .Should() + .BeTrue(); + } + + [Fact] + public void FcDesktop_MustHaveExactlyOneResourceQuota() + { + FcDesktopDocuments() + .Where(document => document.Kind == "ResourceQuota") + .Should() + .ContainSingle(); + } + + [Fact] + public void FcDesktop_ResourceQuotaMustAdoptLiveSessionCapObject() + { + var quota = ResourceQuota(); + + quota.RelativePath.Should().Be("fc-desktop/resourcequota.yaml"); + quota.Name.Should().Be("fc-desktop-session-cap"); + quota.Namespace.Should().Be("fc-desktop"); + } + + [Theory] + [InlineData("count/pods", "15")] + [InlineData("requests.cpu", "8")] + [InlineData("requests.memory", "16Gi")] + [InlineData("requests.storage", "500Gi")] + [InlineData("persistentvolumeclaims", "30")] + public void FcDesktop_ResourceQuotaMustDeclarePhaseOneHardLimits(string key, string value) + { + ResourceQuota().Scalar("spec", "hard", key).Should().Be(value); + } + + [Fact] + public void FcDesktop_ResourceQuotaMustCarryTraceableLabels() + { + ResourceQuotaLabels() + .Should() + .Contain(new Dictionary + { + ["app.kubernetes.io/name"] = "fc-desktop", + ["app.kubernetes.io/part-of"] = "remotedesktop", + ["app.kubernetes.io/component"] = "capacity-guard", + ["app.kubernetes.io/managed-by"] = "argocd", + ["flowercore.io/owner"] = "infra", + }); + } + + [Fact] + public void FcDesktop_ResourceQuotaMustUseRequestsKeysForComputeCap() + { + var hardKeys = HardLimitKeys(ResourceQuota()); + + hardKeys.Should().Contain(new[] { "requests.cpu", "requests.memory" }); + hardKeys.Should().NotContain(new[] { "cpu", "memory" }); + } + + [Fact] + public void FcDesktop_ResourceQuotaMustAvoidDestructiveArgoAnnotations() + { + var quota = ResourceQuota(); + + quota.Scalar("metadata", "annotations", "argocd.argoproj.io/hook").Should().BeNull(); + quota.Scalar("metadata", "annotations", "argocd.argoproj.io/hook-delete-policy").Should().BeNull(); + + var syncOptions = quota.Scalar("metadata", "annotations", "argocd.argoproj.io/sync-options") ?? string.Empty; + syncOptions.Should().NotContain("Force=true"); + syncOptions.Should().NotContain("Replace=true"); + } + + [Fact] + public void FcDesktop_ResourceQuotaMustRecordPhaseAInfraOnlyScope() + { + ResourceQuota().Scalar("metadata", "annotations", "flowercore.io/phase") + .Should() + .Be("sprint-44-cx-9-phase-a"); + } + + [Fact] + public void FcDesktop_MustHaveExactlyOneLimitRange() + { + FcDesktopDocuments() + .Where(document => document.Kind == "LimitRange") + .Should() + .ContainSingle(); + } + + [Fact] + public void FcDesktop_LimitRangeMustLiveBesideResourceQuota() + { + var limitRange = LimitRange(); + + limitRange.RelativePath.Should().Be("fc-desktop/limitrange.yaml"); + limitRange.Name.Should().Be("fc-desktop-pod-defaults"); + limitRange.Namespace.Should().Be("fc-desktop"); + } + + [Fact] + public void FcDesktop_LimitRangeMustHaveSingleContainerRule() + { + var limit = LimitRangeRule(); + + LimitRange().MappingSequence("spec", "limits").Should().ContainSingle(); + ManifestNodeExtensions.Scalar(limit, "type").Should().Be("Container"); + } + + [Theory] + [InlineData("default", "cpu", "1.0")] + [InlineData("default", "memory", "2Gi")] + [InlineData("defaultRequest", "cpu", "500m")] + [InlineData("defaultRequest", "memory", "1Gi")] + [InlineData("max", "cpu", "2.0")] + [InlineData("max", "memory", "4Gi")] + [InlineData("min", "cpu", "100m")] + [InlineData("min", "memory", "128Mi")] + public void FcDesktop_LimitRangeMustDeclarePerPodShape(string section, string key, string value) + { + ManifestNodeExtensions.Scalar(LimitRangeRule(), section, key).Should().Be(value); + } + + [Fact] + public void FcDesktop_LimitRangeMustCarryTraceableLabels() + { + LimitRangeLabels() + .Should() + .Contain(new Dictionary + { + ["app.kubernetes.io/name"] = "fc-desktop", + ["app.kubernetes.io/part-of"] = "remotedesktop", + ["app.kubernetes.io/component"] = "capacity-guard", + ["app.kubernetes.io/managed-by"] = "argocd", + ["flowercore.io/owner"] = "infra", + }); + } + + [Fact] + public void FcDesktop_LimitRangeMustAvoidDestructiveArgoAnnotations() + { + var limitRange = LimitRange(); + + limitRange.Scalar("metadata", "annotations", "argocd.argoproj.io/hook").Should().BeNull(); + limitRange.Scalar("metadata", "annotations", "argocd.argoproj.io/hook-delete-policy").Should().BeNull(); + + var syncOptions = limitRange.Scalar("metadata", "annotations", "argocd.argoproj.io/sync-options") ?? string.Empty; + syncOptions.Should().NotContain("Force=true"); + syncOptions.Should().NotContain("Replace=true"); + } + + [Fact] + public void FcDesktop_LimitRangeMustRecordPhaseAInfraOnlyScope() + { + LimitRange().Scalar("metadata", "annotations", "flowercore.io/phase") + .Should() + .Be("sprint-44-cx-9-phase-a"); + } + + [Fact] + public void FcDesktop_BluejayInfraMustNotOwnDeploymentOrService() + { + FcDesktopDocuments() + .Select(document => document.Kind) + .Should() + .NotContain(new[] { "Deployment", "Service" }); + } + + [Fact] + public void FcDesktop_BluejayInfraMustOnlyOwnInfraResourceKinds() + { + var allowedKinds = new HashSet(StringComparer.Ordinal) + { + "Certificate", + "IngressRoute", + "NetworkPolicy", + "ResourceQuota", + "LimitRange", + }; + + FcDesktopDocuments() + .Select(document => document.Kind) + .Should() + .OnlyContain(kind => allowedKinds.Contains(kind)); + } + + [Fact] + public void FcDesktop_NetworkPolicySetMustRemainPresent() + { + FcDesktopDocuments() + .Where(document => document.Kind == "NetworkPolicy") + .Select(document => document.Name) + .Should() + .BeEquivalentTo( + "desktop-isolation", + "fc-desktop-default-deny", + "remotedesktop-web-isolation", + "cm-acme-http-solver-allow"); + } + + [Fact] + public void FcDesktop_TlsIngressMustRemainOwnedByInfra() + { + FcDesktopDocuments() + .Should() + .Contain(document => document.Kind == "Certificate" && document.Name == "remotedesktop-web-tls") + .And + .Contain(document => document.Kind == "IngressRoute" && document.Name == "remotedesktop-web"); + } + + private static IReadOnlyList FcDesktopDocuments() + { + return Inventory.Documents + .Where(document => document.RelativePath.StartsWith("fc-desktop/", StringComparison.Ordinal)) + .ToList(); + } + + private static ManifestDocument ResourceQuota() + { + return FcDesktopDocuments() + .Single(document => document.Kind == "ResourceQuota"); + } + + private static ManifestDocument LimitRange() + { + return FcDesktopDocuments() + .Single(document => document.Kind == "LimitRange"); + } + + private static YamlMappingNode LimitRangeRule() + { + return LimitRange() + .MappingSequence("spec", "limits") + .Single(); + } + + private static IReadOnlySet HardLimitKeys(ManifestDocument document) + { + var hard = ManifestNodeExtensions.Mapping(document.Root, "spec", "hard") + ?? throw new InvalidOperationException($"{document.Descriptor} is missing spec.hard."); + + return hard.Children.Keys + .OfType() + .Select(key => key.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToHashSet(StringComparer.Ordinal); + } + + private static IReadOnlyDictionary ResourceQuotaLabels() + { + return Labels(ResourceQuota()); + } + + private static IReadOnlyDictionary LimitRangeLabels() + { + return Labels(LimitRange()); + } + + private static IReadOnlyDictionary Labels(ManifestDocument document) + { + var labels = ManifestNodeExtensions.Mapping(document.Root, "metadata", "labels") + ?? throw new InvalidOperationException($"{document.Descriptor} is missing metadata.labels."); + + return labels.Children + .Where(entry => entry.Key is YamlScalarNode && entry.Value is YamlScalarNode) + .ToDictionary( + entry => ((YamlScalarNode)entry.Key).Value ?? string.Empty, + entry => ((YamlScalarNode)entry.Value).Value ?? string.Empty, + StringComparer.Ordinal); + } +} diff --git a/tests/bluejay-infra-lint/FleetManifestLintTests.cs b/tests/bluejay-infra-lint/FleetManifestLintTests.cs index eb9683d..65f3410 100644 --- a/tests/bluejay-infra-lint/FleetManifestLintTests.cs +++ b/tests/bluejay-infra-lint/FleetManifestLintTests.cs @@ -234,7 +234,7 @@ public sealed class FleetManifestLintTests { deployments.Should().ContainKey(expectedRunner.Key); - var container = deployments[expectedRunner.Key].ContainerMappings().Should().ContainSingle().Subject; + var container = RunnerContainer(deployments[expectedRunner.Key]); 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"); @@ -250,7 +250,7 @@ public sealed class FleetManifestLintTests { foreach (var deployment in GitHubRunnerDeployments().Values) { - var container = deployment.ContainerMappings().Should().ContainSingle().Subject; + var container = RunnerContainer(deployment); foreach (var expectedEnv in WritableRunnerEnv) { @@ -430,7 +430,6 @@ public sealed class FleetManifestLintTests var expectedFiles = new[] { "1password-item.yaml", - "argocd-application.yaml", "certificate-web.yaml", "clusterrole-operator.yaml", "clusterrolebinding-operator.yaml", @@ -586,17 +585,15 @@ public sealed class FleetManifestLintTests } [Fact] - public void FcDeviceManagement_ArgocdApplicationMustMatchApplicationSetDiscoveryConventions() + public void FcDeviceManagement_MustRelyOnApplicationSetDiscovery() { - var application = FcDeviceManagementDocuments() - .Single(document => document.Kind == "Application" && document.Name == "infra-fc-devicemgmt"); - - application.Namespace.Should().Be("argocd"); - application.Scalar("spec", "source", "repoURL") + FcDeviceManagementDocuments() .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"); + .NotContain(document => document.Kind == "Application", "the root ApplicationSet owns apps/fc-devicemgmt discovery"); + + FcDeviceManagementDocuments() + .Should() + .Contain(document => document.Kind == "Namespace" && document.Name == "fc-devicemgmt"); } private static IEnumerable ProbeViolations( @@ -631,6 +628,12 @@ public sealed class FleetManifestLintTests .ToDictionary(document => document.Name, StringComparer.Ordinal); } + private static YamlMappingNode RunnerContainer(ManifestDocument deployment) + { + return deployment.ContainerMappings() + .Single(container => string.Equals(ManifestNodeExtensions.Scalar(container, "name"), "runner", StringComparison.Ordinal)); + } + private static int ReplicaCount(ManifestDocument document) { return int.TryParse(document.Scalar("spec", "replicas"), out var replicas) ? replicas : 1;