diff --git a/apps-gx10/fc-apple-mdm/README.md b/apps-gx10/fc-apple-mdm/README.md new file mode 100644 index 0000000..85b83bd --- /dev/null +++ b/apps-gx10/fc-apple-mdm/README.md @@ -0,0 +1,45 @@ +# FlowerCore Apple MDM on GX10 + +This directory deploys the NanoHUB `v0.2.0` substrate for Apple MDM protocol +traffic at `https://mdm.iamworkin.lan`. + +## Runtime + +- Namespace: `fc-apple-mdm` +- Image: `localhost/fc-apple-mdm-nanohub:v0.2.0-20260617` +- Upstream digest: `ghcr.io/micromdm/nanohub:latest@sha256:e36a50db2dc3d2bf736645e58712f622c04b05b28487390981905ef4d0be5fbd` +- Persistent state: `fc-apple-mdm-data` on `local-path`, mounted at `/var/lib/nanohub` +- File backend DSN: `/var/lib/nanohub/db` +- Required secret: `Secret/fc-apple-mdm-runtime`, key `NANOHUB_API_KEY` +- Optional later bridge secret: `NANOHUB_WEBHOOK_URL` +- Required CA mount: `ConfigMap/fc-apple-mdm-root-ca`, key `root_ca.crt` + +NanoHUB API authentication is HTTP Basic with username `nanohub` and password +from `NANOHUB_API_KEY`. + +## Public Surface + +The Traefik route intentionally exposes only: + +- `/version` +- `/mdm` +- `/checkin` + +NanoHUB APIs under `/api/v1/*` stay cluster-internal for MDM-N1. The +DeviceManagement bridge can use the ClusterIP service directly once its NanoHUB +client lane lands. + +## Deployment Notes + +1. Create or refresh the runtime Kubernetes Secret from the 1Password item + `FlowerCore Apple MDM Runtime` before sync. GX10 does not yet depend on the + 1Password operator for this workload. +2. Import `localhost/fc-apple-mdm-nanohub:v0.2.0-20260617` into GX10 containerd + before ArgoCD syncs. The deployment uses `imagePullPolicy: Never`. +3. Ensure `mdm.iamworkin.lan` resolves to the GX10 Traefik VIP `10.0.57.202` + before cert-manager requests `Certificate/fc-apple-mdm-tls`. +4. Prove `https://mdm.iamworkin.lan/version` after ArgoCD converges. + +This lane does not create an APNs MDM push certificate, enrollment profile, +SCEP/device identity service, managed Wi-Fi payload, managed app install, or +supervised iPad enrollment. Those remain MDM-N2 through MDM-N8. diff --git a/apps-gx10/fc-apple-mdm/fc-apple-mdm.yaml b/apps-gx10/fc-apple-mdm/fc-apple-mdm.yaml new file mode 100644 index 0000000..db5b6e0 --- /dev/null +++ b/apps-gx10/fc-apple-mdm/fc-apple-mdm.yaml @@ -0,0 +1,280 @@ +# FlowerCore Apple MDM NanoHUB workload for the GX10 cluster. +# Secret values are copied into Kubernetes Secrets out of band until the +# 1Password operator exists on GX10; never commit secret data here. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: fc-apple-mdm + labels: + app.kubernetes.io/part-of: flowercore +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: fc-apple-mdm-root-ca + namespace: fc-apple-mdm +data: + root_ca.crt: | + -----BEGIN CERTIFICATE----- + MIIBxDCCAWqgAwIBAgIRAPY357G6ow6zMAL5+4bS2kkwCgYIKoZIzj0EAwIwQDEa + MBgGA1UEChMRSUFtV29ya2luIEFDTUUgQ0ExIjAgBgNVBAMTGUlBbVdvcmtpbiBB + Q01FIENBIFJvb3QgQ0EwHhcNMjYwMzA4MTgwNzExWhcNMzYwMzA1MTgwNzExWjBA + MRowGAYDVQQKExFJQW1Xb3JraW4gQUNNRSBDQTEiMCAGA1UEAxMZSUFtV29ya2lu + IEFDTUUgQ0EgUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ2n04X1 + JZo5Zdq/i1Idv8+fqwZyAzBh7whbqj0SWsJL8UWRabCMqYCs7+dXO0xRSzqkwFDL + x+vooOai8RgRNhajRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/ + AgEBMB0GA1UdDgQWBBRnuPPQR6iM/H6vOluiU3Sygayz8jAKBggqhkjOPQQDAgNI + ADBFAiEArQK9dYPGmAZsdYnjziuFVVE5NKZUcceYvGfGC+tLXUsCIAudF2zJrCRq + 3mK50ZZET/fwTkJwiEF4824mjP8p1CKM + -----END CERTIFICATE----- +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: fc-apple-mdm-data + namespace: fc-apple-mdm + labels: + app: fc-apple-mdm + app.kubernetes.io/name: fc-apple-mdm + app.kubernetes.io/part-of: flowercore +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 2Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fc-apple-mdm + namespace: fc-apple-mdm + labels: + app: fc-apple-mdm + app.kubernetes.io/name: fc-apple-mdm + app.kubernetes.io/component: mdm + app.kubernetes.io/part-of: flowercore +spec: + replicas: 1 + revisionHistoryLimit: 3 + strategy: + type: Recreate + selector: + matchLabels: + app: fc-apple-mdm + template: + metadata: + labels: + app: fc-apple-mdm + app.kubernetes.io/name: fc-apple-mdm + app.kubernetes.io/component: mdm + app.kubernetes.io/part-of: flowercore + annotations: + fc.flowercore.io/healthz-anon: "true" + fc.flowercore.io/probe-path: "/version" + flowercore.io/audit-trace-id: "apple-mdm-nanohub-runtime-trace" + flowercore.io/root-ca-sha256: "a9120c88fa3ec735d790aa4cfeb61ac2946730338969015bebaccc08fe10535e" + prometheus.io/scrape: "false" + spec: + enableServiceLinks: false + securityContext: + runAsNonRoot: true + runAsUser: 1654 + runAsGroup: 1654 + fsGroup: 1654 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: nanohub + image: localhost/fc-apple-mdm-nanohub:v0.2.0-20260617 + imagePullPolicy: Never + ports: + - name: http + containerPort: 9004 + protocol: TCP + env: + - name: HOME + value: "/var/lib/nanohub" + - name: NANOHUB_LISTEN + value: ":9004" + - name: NANOHUB_STORAGE + value: "file" + - name: NANOHUB_STORAGE_DSN + value: "/var/lib/nanohub/db" + - name: NANOHUB_CHECKIN + value: "true" + - name: NANOHUB_CA + value: "/etc/nanohub/ca/root_ca.crt" + - name: NANOHUB_API_KEY + valueFrom: + secretKeyRef: + name: fc-apple-mdm-runtime + key: NANOHUB_API_KEY + - name: NANOHUB_WEBHOOK_URL + valueFrom: + secretKeyRef: + name: fc-apple-mdm-runtime + key: NANOHUB_WEBHOOK_URL + optional: true + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + startupProbe: + httpGet: + path: /version + port: 9004 + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 30 + readinessProbe: + httpGet: + path: /version + port: 9004 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + tcpSocket: + port: 9004 + initialDelaySeconds: 30 + periodSeconds: 30 + failureThreshold: 3 + securityContext: + runAsNonRoot: true + runAsUser: 1654 + runAsGroup: 1654 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + volumeMounts: + - name: data + mountPath: /var/lib/nanohub + - name: tmp + mountPath: /tmp + - name: root-ca + mountPath: /etc/nanohub/ca + readOnly: true + volumes: + - name: data + persistentVolumeClaim: + claimName: fc-apple-mdm-data + - name: tmp + emptyDir: {} + - name: root-ca + configMap: + name: fc-apple-mdm-root-ca + items: + - key: root_ca.crt + path: root_ca.crt +--- +apiVersion: v1 +kind: Service +metadata: + name: fc-apple-mdm + namespace: fc-apple-mdm + labels: + app: fc-apple-mdm + app.kubernetes.io/name: fc-apple-mdm + app.kubernetes.io/part-of: flowercore +spec: + type: ClusterIP + selector: + app: fc-apple-mdm + ports: + - name: http + port: 80 + targetPort: 9004 + protocol: TCP +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: fc-apple-mdm-tls + namespace: fc-apple-mdm + annotations: + flowercore.io/dns-preflight: "mdm.iamworkin.lan must resolve to 10.0.57.202 before ACME sync" +spec: + secretName: fc-apple-mdm-tls + issuerRef: + name: step-ca-acme + kind: ClusterIssuer + dnsNames: + - mdm.iamworkin.lan + duration: 720h + renewBefore: 240h +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: fc-apple-mdm + namespace: fc-apple-mdm +spec: + entryPoints: + - websecure + routes: + - match: Host(`mdm.iamworkin.lan`) && (PathPrefix(`/mdm`) || PathPrefix(`/checkin`) || PathPrefix(`/version`)) + kind: Rule + services: + - name: fc-apple-mdm + port: 80 + tls: + secretName: fc-apple-mdm-tls +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: fc-apple-mdm-netpol + namespace: fc-apple-mdm +spec: + podSelector: + matchLabels: + app: fc-apple-mdm + policyTypes: + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: traefik-system + ports: + - port: 9004 + protocol: TCP + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: fc-devicemgmt + ports: + - port: 9004 + protocol: TCP + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - port: 443 + protocol: TCP + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: fc-devicemgmt + ports: + - port: 80 + protocol: TCP + - port: 8080 + protocol: TCP diff --git a/apps-gx10/fc-apple-mdm/kustomization.yaml b/apps-gx10/fc-apple-mdm/kustomization.yaml new file mode 100644 index 0000000..6c04914 --- /dev/null +++ b/apps-gx10/fc-apple-mdm/kustomization.yaml @@ -0,0 +1,6 @@ +# ArgoCD discovers apps-gx10/* directories on the GX10 GitOps branch. +# This kustomization is for local previews and single-app validation. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - fc-apple-mdm.yaml diff --git a/tests/bluejay-infra-lint/Gx10AppleMdmNanohubTests.cs b/tests/bluejay-infra-lint/Gx10AppleMdmNanohubTests.cs new file mode 100644 index 0000000..85db88b --- /dev/null +++ b/tests/bluejay-infra-lint/Gx10AppleMdmNanohubTests.cs @@ -0,0 +1,179 @@ +using FluentAssertions; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace BluejayInfraLint.Tests; + +[Trait("Category", "Unit")] +public sealed class Gx10AppleMdmNanohubTests +{ + private static readonly string Root = FindRepoRoot(); + private static readonly string AppRoot = Path.Combine(Root, "apps-gx10", "fc-apple-mdm"); + private static readonly IReadOnlyList Documents = LoadDocuments(); + + [Fact] + public void Manifest_DeclaresLockedDownNanoHubRuntime() + { + Documents.Should().Contain(document => Is(document, "Namespace", "fc-apple-mdm")); + Documents.Should().Contain(document => Is(document, "ConfigMap", "fc-apple-mdm-root-ca")); + Documents.Should().Contain(document => Is(document, "Service", "fc-apple-mdm")); + Documents.Should().Contain(document => Is(document, "NetworkPolicy", "fc-apple-mdm-netpol")); + Documents.Should().NotContain(document => (document.Scalar("kind") ?? string.Empty) == "Secret"); + Documents.Should().NotContain(document => (document.Scalar("kind") ?? string.Empty) == "OnePasswordItem"); + + var pvc = Single("PersistentVolumeClaim", "fc-apple-mdm-data"); + pvc.Scalar("spec", "storageClassName").Should().Be("local-path"); + pvc.Scalar("spec", "resources", "requests", "storage").Should().Be("2Gi"); + + var deployment = Single("Deployment", "fc-apple-mdm"); + deployment.Scalar("spec", "strategy", "type").Should().Be("Recreate"); + deployment.Scalar("spec", "template", "metadata", "annotations", "fc.flowercore.io/probe-path").Should().Be("/version"); + deployment.Scalar("spec", "template", "metadata", "annotations", "flowercore.io/root-ca-sha256") + .Should() + .Be("a9120c88fa3ec735d790aa4cfeb61ac2946730338969015bebaccc08fe10535e"); + + var maybePodSpec = deployment.Mapping("spec", "template", "spec"); + maybePodSpec.Should().NotBeNull(); + var podSpec = maybePodSpec!; + podSpec.Scalar("enableServiceLinks").Should().Be("false"); + podSpec.Scalar("securityContext", "runAsUser").Should().Be("1654"); + podSpec.Scalar("securityContext", "runAsNonRoot").Should().Be("true"); + + var container = podSpec.MappingSequence("containers").Should().ContainSingle().Subject; + container.Scalar("name").Should().Be("nanohub"); + container.Scalar("image").Should().Be("localhost/fc-apple-mdm-nanohub:v0.2.0-20260617"); + container.Scalar("imagePullPolicy").Should().Be("Never"); + container.Scalar("securityContext", "readOnlyRootFilesystem").Should().Be("true"); + container.Scalar("securityContext", "allowPrivilegeEscalation").Should().Be("false"); + + EnvValue(container, "NANOHUB_LISTEN").Should().Be(":9004"); + EnvValue(container, "NANOHUB_STORAGE").Should().Be("file"); + EnvValue(container, "NANOHUB_STORAGE_DSN").Should().Be("/var/lib/nanohub/db"); + EnvValue(container, "NANOHUB_CHECKIN").Should().Be("true"); + EnvValue(container, "NANOHUB_CA").Should().Be("/etc/nanohub/ca/root_ca.crt"); + EnvSecretName(container, "NANOHUB_API_KEY").Should().Be("fc-apple-mdm-runtime"); + EnvSecretKey(container, "NANOHUB_API_KEY").Should().Be("NANOHUB_API_KEY"); + EnvSecretName(container, "NANOHUB_WEBHOOK_URL").Should().Be("fc-apple-mdm-runtime"); + EnvSecretKey(container, "NANOHUB_WEBHOOK_URL").Should().Be("NANOHUB_WEBHOOK_URL"); + EnvSecretOptional(container, "NANOHUB_WEBHOOK_URL").Should().Be("true"); + + VolumeMount(container, "data").Scalar("mountPath").Should().Be("/var/lib/nanohub"); + VolumeMount(container, "root-ca").Scalar("mountPath").Should().Be("/etc/nanohub/ca"); + VolumeMount(container, "root-ca").Scalar("readOnly").Should().Be("true"); + ProbePath(container, "startupProbe").Should().Be("/version"); + ProbePath(container, "readinessProbe").Should().Be("/version"); + container.Scalar("livenessProbe", "tcpSocket", "port").Should().Be("9004"); + } + + [Fact] + public void Manifest_ExposesOnlyMdmCheckinAndVersionPaths() + { + var certificate = Single("Certificate", "fc-apple-mdm-tls"); + certificate.Scalar("spec", "issuerRef", "name").Should().Be("step-ca-acme"); + certificate.Scalar("spec", "issuerRef", "kind").Should().Be("ClusterIssuer"); + certificate.ScalarSequence("spec", "dnsNames").Should().ContainSingle("mdm.iamworkin.lan"); + + var ingress = Single("IngressRoute", "fc-apple-mdm"); + var route = ingress.MappingSequence("spec", "routes").Should().ContainSingle().Subject; + var match = route.Scalar("match"); + + match.Should().Contain("Host(`mdm.iamworkin.lan`)"); + match.Should().Contain("PathPrefix(`/mdm`)"); + match.Should().Contain("PathPrefix(`/checkin`)"); + match.Should().Contain("PathPrefix(`/version`)"); + match.Should().NotContain("/api/v1"); + match.Should().NotContain("PathPrefix(`/api`)"); + } + + [Fact] + public void Readme_DocumentsSecretImportAndSupportBoundary() + { + var readme = File.ReadAllText(Path.Combine(AppRoot, "README.md")); + + readme.Should().Contain("FlowerCore Apple MDM Runtime"); + readme.Should().Contain("Secret/fc-apple-mdm-runtime"); + readme.Should().Contain("imagePullPolicy: Never"); + readme.Should().Contain("10.0.57.202"); + readme.Should().Contain("does not create an APNs MDM push certificate"); + readme.Should().Contain("managed Wi-Fi payload"); + } + + private static YamlMappingNode Single(string kind, string name) + { + return Documents.Single(document => Is(document, kind, name)); + } + + private static bool Is(YamlMappingNode document, string kind, string name) + { + return document.Scalar("kind") == kind + && document.Scalar("metadata", "name") == name; + } + + private static string? EnvValue(YamlMappingNode container, string name) + { + return EnvMapping(container, name)?.Scalar("value"); + } + + private static string? EnvSecretName(YamlMappingNode container, string name) + { + return EnvMapping(container, name)?.Scalar("valueFrom", "secretKeyRef", "name"); + } + + private static string? EnvSecretKey(YamlMappingNode container, string name) + { + return EnvMapping(container, name)?.Scalar("valueFrom", "secretKeyRef", "key"); + } + + private static string? EnvSecretOptional(YamlMappingNode container, string name) + { + return EnvMapping(container, name)?.Scalar("valueFrom", "secretKeyRef", "optional"); + } + + private static string? ProbePath(YamlMappingNode container, string probeKey) + { + return container.Scalar(probeKey, "httpGet", "path"); + } + + private static YamlMappingNode VolumeMount(YamlMappingNode container, string name) + { + return container.MappingSequence("volumeMounts") + .Single(mount => mount.Scalar("name") == name); + } + + private static YamlMappingNode? EnvMapping(YamlMappingNode container, string name) + { + return container.MappingSequence("env") + .SingleOrDefault(env => env.Scalar("name") == name); + } + + private static IReadOnlyList LoadDocuments() + { + var stream = new YamlStream(); + using var reader = File.OpenText(Path.Combine(AppRoot, "fc-apple-mdm.yaml")); + stream.Load(reader); + + return stream.Documents + .Select(document => document.RootNode) + .OfType() + .Where(mapping => !string.IsNullOrWhiteSpace(mapping.Scalar("kind"))) + .ToList(); + } + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, "apps-gx10")) + && Directory.Exists(Path.Combine(current.FullName, "tests")) + && File.Exists(Path.Combine(current.FullName, "README.md"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not find bluejay-infra root."); + } +}