Compare commits

..

86 Commits

Author SHA1 Message Date
Andrew Stoltz
eae7b4ed7a infra(cx2-5): DNS auth/NetPol substrate, air-gap landing, arm64 ARC runner + tenant landing manifests
- fc-dns: add OnePasswordItem CRD for DNS API keys + NetworkPolicy for Phase 0 auth hardening; bump dns-web image tag
- fc-landing: rewrite landing HTML to remove CDN dependencies (air-gap safe); add preview.html standalone preview
- github-runner: add TOOLCACHE_ARCH to install-ruby-toolcache.sh for arm64 support; add Dockerfile.arm64 for arm64 ARC runner image
- docs/gx10-tenant-landing: per-user Deployment+IngressRoute manifests (andrew/dustin/erik/fit/matt) + CUTOVER-RUNBOOK.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:53:26 -05:00
Andrew Stoltz
9dd170a9ac deploy(chat): route wave5 chat ollama to edge1 2026-06-13 22:59:18 -05:00
Andrew Stoltz
50a3ee5e8e deploy(chat): enable helpdesk sentiment escalation 2026-06-13 22:51:21 -05:00
Andrew Stoltz
87de007a7f deploy(wave5): roll deep-regroup product images 2026-06-13 22:48:31 -05:00
Andrew Stoltz
77df227425 deploy(intranet): roll product docs image 2026-06-13 20:23:08 -05:00
Andrew Stoltz
a65f422147 infra(gated): stage authentik-tenant-mapping-sync CronJob (Au-3, suspended)
Gated substrate (Cl2-4 / Cl-infra-3) — outside apps/ so the ApplicationSet
will not deploy it, and spec.suspend: true. Reconciles the 1Password
tenant-mapping doc into Authentik groups via Connect REST. Activate at Au-3
public-go (un-suspend + materialize the script ConfigMap). Pairs Codex Cx2-7.
Canonical script: FlowerCore.Notes/scripts/authentik/authentik-tenant-mapping-sync.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:34:29 -05:00
Andrew Stoltz
6cb54abfa7 perf(intranet): repoint embed backend to BLUEJAY-AI GPU (10.0.56.132) for faster bulk embed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:20:28 -05:00
Andrew Stoltz
d06637b747 deploy(cx2-1): roll chat and intranet wave images 2026-06-13 17:18:11 -05:00
Andrew Stoltz
387097485e infra(public-tls): add gated Let's Encrypt issuers + tenant NetworkPolicy substrate
Cl-infra-2 (deep-regroup 2026-06-13). LE staging+prod ClusterIssuers (HTTP-01
via Traefik, DNS-01 stub) + a per-tenant default-deny NetworkPolicy template,
under gated/public-tls/ OUTSIDE apps/ so the ApplicationSet does NOT auto-apply
them (an applied ACME ClusterIssuer registers an account immediately). Internal
*.iamworkin.lan TLS stays on step-ca. Inert until the operator opens the
web-hosting public-exposure gate (R-1; 14/14 blockers red). Pairs with Codex
Wh-C1 (hybrid public TLS) + Wh-C2 (isolation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 12:06:31 -05:00
Andrew Stoltz
b098604a6f fix(intranet): point IntranetSearch embed backend at edge1 by IPv4 (10.0.57.17)
The hostname edge1.iamworkin.lan resolves to an unroutable IPv6 from cluster
pods and the CoreDNS *.iamworkin.lan template maps it to the Traefik VIP, so
the corpus indexer failed every embed with "No route to host". edge1's IPv4
(10.0.57.17, PROD VLAN) is pod-routable and has nomic-embed-text; an in-pod
embed test returned real vectors. This makes the now-enabled notes-md/notes-html
indexes actually populate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:55:26 -05:00
Andrew Stoltz
110d6fd1e0 infra(intranet): mount Notes docs corpus + enable IntranetSearch indexer
Cl-infra-1 (deep-regroup 2026-06-13). Adds a notes-corpus-clone initContainer
(shallow git clone of bluejay/FlowerCore.Notes into an emptyDir at
/srv/flowercore-notes) + a notes-corpus-sync sidecar (30-min pull) and flips
IntranetSearch__Enabled false->true so the previously doubly-disabled indexer
has a corpus to index (768 md + 108 html under docs/).

- Trailing-dot FQDN gitea-clusterip.gitea.svc.cluster.local. bypasses a CoreDNS
  *.iamworkin.lan template that mis-resolves the in-cluster service name to the
  Traefik VIP for musl / ndots:5 pods (search-domain appending).
- Cred via gitea-corpus-cred secret (canonical 1P bluejay read cred, created
  imperatively in-ns; mirrors the gitea-flowercore-notes argocd repo-cred pattern).
- First-boot bulk embed runs in background via edge1 Ollama; /health stays Ready.

Pairs with Codex In-1 (intranet app-side reindex endpoint + SemaphoreSlim).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:48:24 -05:00
Andrew Stoltz
6b2e6a61d0 deploy(dns): roll hosting quota image 2026-06-13 02:06:40 -05:00
Andrew Stoltz
503685d0f5 deploy(devicemgmt): roll windows update policy image 2026-06-13 00:46:30 -05:00
Andrew Stoltz
05f37df5d2 deploy(devicemgmt): roll sqlite-safe trust bundle image 2026-06-13 00:12:13 -05:00
Andrew Stoltz
f3afa64c5d deploy(devicemgmt): roll edge network enrollment image 2026-06-13 00:04:44 -05:00
Andrew Stoltz
b4a1cb63f0 deploy: roll dns tenant repeat fix image 2026-06-12 22:54:30 -05:00
Andrew Stoltz
d95aa453ea deploy: roll dns web repeatable tenant image 2026-06-12 22:45:13 -05:00
Andrew Stoltz
0bbba2739c deploy: roll devicemgmt ollama gateway image 2026-06-12 22:16:15 -05:00
Andrew Stoltz
99f49c1b75 deploy: roll devicemgmt patch ledger image 2026-06-12 21:55:07 -05:00
Andrew Stoltz
14a0e87513 deploy: roll devicemgmt sqlite enrollment fix 2026-06-12 21:32:49 -05:00
Andrew Stoltz
d2e8b5f4a8 deploy: roll devicemgmt enrollment image 2026-06-12 21:26:22 -05:00
Andrew Stoltz
861ed42e2c deploy: roll e4 conformance web images 2026-06-12 19:48:07 -05:00
Andrew Stoltz
605073c299 deploy(devicemgmt): roll e3 ollama policy pack image 2026-06-12 19:27:08 -05:00
Andrew Stoltz
346b287a3d chore(fc-devicemgmt): bump web to v20260612-hubfix-afa9f4d (DeviceAgentHub ct-param enrollment outage fix)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:10:37 -05:00
Andrew Stoltz
6bd02f5781 chore(worldbuilder): deploy C7 next arc image 2026-06-12 18:08:54 -05:00
Andrew Stoltz
2a2b416d12 chore(dns): deploy C4 tenant onboarding image 2026-06-12 17:47:37 -05:00
Andrew Stoltz
d3ae09865a chore(chat): deploy C8 action execution image 2026-06-12 17:24:55 -05:00
Andrew Stoltz
637a8ffd69 chore(devicemgmt): deploy C13 policy web image 2026-06-12 17:01:37 -05:00
Andrew Stoltz
6ab232761d chore(ttsreader): bump fc-ttsreader-web to v20260612-ui-conformance (FC UI conformance D5)
Gold PWA primary CTA (mobile-button--primary blue->gold cascade fix) + About
operator jump-links / honest update-status / license (FcAboutPanel contract).
Image built + imported to rke2-server + rke2-agent1; pin so ArgoCD adopts the
new tag instead of reverting the kubectl set image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:51:57 -05:00
Andrew Stoltz
bfe42cf44e feat(fc-network): add FlowerCore.Network app (read-only pfSense plane, ADR-189)
Stand up the pfSense automation plane (Phase 0, read-only) on RKE2 as an
ArgoCD-managed workload at network.iamworkin.lan.

- namespace fc-network
- Deployment fc-network-web: localhost/fc-network-web:v20260612-0b5b049,
  imagePullPolicy Never, port 5340, /healthz probes, runAsNonRoot 1654 +
  readOnlyRootFilesystem, RWO-safe RollingUpdate (maxSurge 0/maxUnavailable 1),
  auth gate-OFF, SQLite + snapshot-store + intended-model paths under /data.
- PVC fc-network-web-data (longhorn, 2Gi): SQLite index + on-box snapshot store
  (full-fidelity raw config.xml stays on-box; service surfaces redacted only).
- Service (ClusterIP 80 -> 5340), Certificate (ClusterIssuer step-ca-acme),
  IngressRoute (network.iamworkin.lan, all methods — POST ingest is local-only).
- kustomization.yaml for local previews / single-app validation.

The ApplicationSet git generator picks this up as infra-fc-network; if it lags,
the Application is applied manually (documented pattern).
2026-06-12 14:21:45 -05:00
Andrew Stoltz
bf96f7b9a2 deploy(devicemgmt): use rwo-safe rolling strategy 2026-06-12 12:42:20 -05:00
Andrew Stoltz
8be054f99a deploy(devicemgmt): use recreate for sqlite pvc rollout 2026-06-12 12:38:05 -05:00
Andrew Stoltz
6abb2d6408 deploy(devicemgmt): roll L8 web image 2026-06-12 12:33:15 -05:00
Andrew Stoltz
8e2c960be3 deploy(dns): align l4 image and auth gate 2026-06-12 12:10:23 -05:00
Andrew Stoltz
c482b66187 deploy(worldbuilder): bump image to v202606121657-35aaa2c-gpu (L2 UI sweep)
Ships the L2 pilot UI sweep to worldbuilder.iamworkin.lan: the dashboard
fc-component fix (missing-styles), ComfyUI local detection, and the rebuilt
About page. Image imported to rke2-server (10.0.56.11) + rke2-agent1
(10.0.56.12). rke2-agent2/10.0.56.13 is retired and was not used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:01:16 -05:00
Andrew Stoltz
bacb756173 feat(fc-desktop): OnePasswordItem CRD for remotedesktop-oidc-client (L9 flip-readiness, gate stays OFF)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:31:07 -05:00
Andrew Stoltz
8a576c95ed deploy(fc-ttsreader): v20260612-readalong-corrections
TtsReader master@355a9c6: global pronunciation correction memory
(/corrections + REST/MCP), public read-along embed manifests with
fc-reader single-file cue windows (Common@639e233), mood gathering
timelines, listening-note capture, approved-only render contract fix,
and Codex Phase 14.2 rehearsal cue sheets (#42). Tests 1609/1609.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:07:37 -05:00
Andrew Stoltz
41c2243f09 deploy(intranet): roll screenshot metadata image 2026-06-12 01:15:23 -05:00
Andrew Stoltz
c21e602e4d deploy(intranet): roll page reading profile image 2026-06-12 00:34:21 -05:00
Andrew Stoltz
9f6b71c400 deploy(intranet): roll remotedesktop api ref image 2026-06-11 19:23:07 -05:00
Andrew Stoltz
26f90acf1f deploy(intranet): roll platform badge image 2026-06-11 18:59:25 -05:00
Andrew Stoltz
ab00d22657 deploy(worldbuilder): roll route fix image 2026-06-11 16:17:17 -05:00
Andrew Stoltz
c1a43c64b3 deploy(worldbuilder): enable live gpu backend 2026-06-11 16:05:40 -05:00
Andrew Stoltz
7103658342 deploy(intranet): roll regroup follow-through image 2026-06-11 15:58:12 -05:00
Andrew Stoltz
6b12b2bb49 deploy(intranet): roll operator depth image 2026-06-11 15:06:08 -05:00
Andrew Stoltz
a4c9e44a36 fix(runners): disable self-update in k8s pods 2026-06-11 14:57:00 -05:00
Andrew Stoltz
9674a9555e deploy(intranet): roll article depth image 2026-06-11 14:27:24 -05:00
Andrew Stoltz
318252da76 deploy(devicemgmt): roll healthz web image 2026-06-11 14:27:14 -05:00
Andrew Stoltz
3798b7c00e deploy(devicemgmt): enable web runtime 2026-06-11 14:21:51 -05:00
Andrew Stoltz
2707f1ae1e deploy(intranet): roll regroup catalog image 2026-06-11 12:32:40 -05:00
Andrew Stoltz
a7e7c1ae72 deploy(intranet): roll content quality image 2026-06-10 20:13:56 -05:00
Andrew Stoltz
c8df788d72 deploy(intranet): roll webmail health image 2026-06-10 19:15:44 -05:00
Andrew Stoltz
b1a4d7120e deploy(intranet): roll registry health image 2026-06-10 19:10:31 -05:00
Andrew Stoltz
4b57b8e939 fix(intranet): align search deploy config 2026-06-10 19:01:08 -05:00
Andrew Stoltz
70f36c546b deploy(intranet): roll hardening image 2026-06-10 18:58:09 -05:00
Robot
cdbddd71af fc-devicemgmt: stage fresh web image v20260610-bluejay (master 1614fce)
Image built from current DM master (network/BT command plane + Blue Jay
UI.Components restyle) and imported on rke2-server + rke2-agent1.
Deployment stays parked at replicas: 0 — gap 1 is wider than previously
noted (the fc-mysql Operator deployment itself is absent, so instance
CRDs would not reconcile) and gap 2 (1P runtime item) is still open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:57:43 -05:00
Andrew Stoltz
81ac1f3e4f authentik: align volumeClaimTemplates TypeMeta with SSA-created live object
StatefulSet/authentik-postgres has been eternally OutOfSync since ~Sprint 65
even though 'kubectl diff --server-side --field-manager=argocd-controller'
shows zero real change. The STS was created via ServerSideApply, so the live
object carries apiVersion/kind inside volumeClaimTemplates[]; git omitting
them makes ArgoCD's normalized diff disagree forever. Declare them in git.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:18:29 -05:00
b842738a0e Merge pull request 'Sprint 63 Cx-10: align hardening probe paths with live routes' (#44) from codex/s63-cx10 into main
Sprint 63 Cx-10 live-proof fix after Traefik curls found three stale probe-path annotations. Local lint 100/100; git diff --check clean; no Gitea statuses attached.
2026-06-05 03:02:14 +00:00
Andrew Stoltz
f0cb7a5e81 fix(hardening): align probe-path annotations with live health routes 2026-06-04 22:01:04 -05:00
ac0f665323 Merge pull request 'Draft: Sprint 62 Cx-10 broader exposure hardening' (#43) from codex/s62-cx10 into main
Sprint 63 Cx-10 reconcile-first merge after local lint proof: 100/100 passed, no Gitea statuses attached, CRLF diff check clean.
2026-06-05 02:51:37 +00:00
Andrew Stoltz
c4b08f41ab feat(infra): prestage broader app exposure hardening 2026-06-04 18:14:22 -05:00
Andrew Stoltz
417d3830ae test(lint): reconcile baseline infra assertions 2026-06-04 18:02:32 -05:00
cb4ea13e7a monitoring: mirror Sprint 60 probe coverage
Merged on local lint plus live noc1 Prometheus /api/v1/rules proof.
2026-06-04 18:19:47 +00:00
Andrew Stoltz
a3cd67d6bb monitoring: mirror Sprint 60 probe coverage 2026-06-04 13:15:18 -05:00
Andrew Stoltz
81a3ddac4c fix(auth): mark OIDC healthz probes anonymous 2026-06-04 11:03:20 -05:00
300f8ad546 fix(monitoring): probe OIDC-safe health routes
Sprint 58 Cx-12. Rebased over OIDC GitOps main; YAML parse and focused bluejay-infra lint tests passed.
2026-06-04 06:45:34 +00:00
fe38c2641f Merge pull request 'fix(auth): deploy distribution root anonymous image' (#38) from codex/s58-distribution-root-anon-gitops into main 2026-06-04 06:20:09 +00:00
Andrew Stoltz
3b40dfb185 fix(auth): deploy distribution root anonymous image 2026-06-04 01:19:16 -05:00
103878671c Merge pull request 'fix(auth): deploy Distribution OIDC image tag' (#37) from codex/s58-oidc-proper into main 2026-06-04 06:05:15 +00:00
Andrew Stoltz
36039c1335 fix(auth): deploy distribution oidc image tag 2026-06-04 01:04:44 -05:00
2a66109f13 Merge pull request 'feat(auth): adopt OIDC GitOps for DNS Distribution Media' (#36) from codex/s58-oidc-proper into main 2026-06-04 05:52:56 +00:00
Andrew Stoltz
933fea89d1 feat(auth): adopt oidc apps in gitops 2026-06-04 00:49:36 -05:00
Andrew Stoltz
13f9bb7710 fix(distribution): revert OIDC enforcement — enabling it gated /healthz probe (service down)
Flipping Auth__Enabled=true gated the /healthz readiness probe (302->NotReady->
no endpoints->distribution.iamworkin.lan down, healthz=000). Classic
feedback_k8s_probes_behind_auth_middleware. Revert to false (OIDC env block kept,
gate off) to restore service. Proper fix (AllowAnonymous /healthz + CA-trust +
idempotent Editions seed + OIDC-challenge wiring + browser-proof) -> falcon OIDC lane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:47:29 -05:00
Andrew Stoltz
9a58fd2af6 oidc: flip enforcement ON for knowledge + distribution (no-live-proof, fix-forward)
Operator 2026-06-04: nothing is production yet, flip OIDC + fix-forward (no
browser-proof gate). knowledge: Auth__Enabled false->true (OIDC env already
wired). distribution: add OIDC env block (Authority/Audience/ClientId=distribution,
ClientSecret from distribution-oidc-client) + Enabled=true; public read/entitlement
+ Method() allowlist stay open (OIDC gates admin only). Clients already provisioned
(secrets present). ArgoCD deploys both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:38:48 -05:00
Andrew Stoltz
404d884863 Adopt live Library Retail AiStation web apps 2026-06-03 20:24:32 -05:00
f4bd90f805 Merge pull request #33 from codex/s56-monitoring-coverage
fix(monitoring): repoint pirelay scrape to signalcontrol
2026-06-04 01:22:49 +00:00
Andrew Stoltz
67d67ab73d fix(monitoring): repoint pirelay scrape to signalcontrol 2026-06-03 20:20:36 -05:00
Andrew Stoltz
f7d41cdc60 revert: drop fc-library manifest — Library.Web already deployed live (41h)
Library.Web is already running + serving at library.iamworkin.lan (root=200,
healthz=200), deployed manually 41h ago (image fc-library-web:v20260602-...,
PVC library-web-data holding the live SQLite DB). My from-scratch manifest used
a different PVC name (library-data) which ArgoCD would attach as a fresh empty
volume, orphaning the live DB. Adopting the live deploy into GitOps is a
separate careful task. Not disturbing a working deployment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:30:23 -05:00
Andrew Stoltz
2c0afc28e4 deploy(fc-library): add Library.Web internal-host deployment
From-scratch .Web deploy at library.iamworkin.lan (operator-authorized 2026-06-03).
Cloned from the worldbuilder pattern: Deployment + Service + Longhorn RWO PVC +
step-ca cert + Traefik IngressRoute. SQLite at /data/library.db, no OIDC, both
/health + /healthz probes. Image localhost/fc-library:v202606031925 imported to
both RKE2 nodes. DNS library.iamworkin.lan -> 10.0.56.200 already in pfSense.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:28:22 -05:00
Robot
ba5f5dd0fb deploy(knowledge): roll audit backfill fix 2026-06-03 18:24:22 -05:00
Robot
dc699da7b3 fix(knowledge): persist federation database on PVC 2026-06-03 18:17:31 -05:00
Robot
1e8bf54c6e deploy: roll Chat and Knowledge OIDC images 2026-06-03 18:13:09 -05:00
Andrew Stoltz
e2e93d482c Deploy TtsReader schema repair image
Co-Authored-By: Codex <codex@openai.com>
2026-06-02 22:00:15 -05:00
4319cc2b51 Merge PR #32: divoom pi deploy artifact manifests
Lands Divoom-as-DM-device and Divoom-TV Pi HDMI deploy artifacts for Cx-6.
2026-06-03 02:47:36 +00:00
Andrew Stoltz
2bf339ce51 Deploy TtsReader PR29 live proof image
Co-Authored-By: Codex <codex@openai.com>
2026-06-02 21:47:04 -05:00
Andrew Stoltz
5bdedfc5ae divoom: add pi deploy artifact manifests
Add source-controlled Puppet/Hiera contracts for edge2 Divoom-as-DM-device without replacing the live flowercore-divoom systemd deployment.

Add Divoom TV Pi HDMI systemd/Puppet deployment artifacts, LF shell-script guardrails, and focused lint coverage for the additive non-K8s deploy shape.

Co-Authored-By: Codex <codex@openai.com>
2026-06-02 21:45:27 -05:00
82 changed files with 7025 additions and 1147 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
/.gitattributes text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.sh text eol=lf

View File

@@ -116,6 +116,16 @@ dotnet test tests/bluejay-infra-lint/BluejayInfraLint.Tests.csproj -c Release
That test project sweeps `bluejay-infra/apps/**` plus the canonical sibling `FlowerCore.*\\k8s` manifests that share the same workspace. Matching `conftest.dev` policy files live under `tests/bluejay-infra-lint/conftest.dev/` for environments that also have `conftest` or `opa`.
## Non-K8s Pi Artifacts
Some `apps/*` directories are deployment artifact bundles consumed by Puppet
instead of Kubernetes workloads. `apps/fc-signage-pi-player/` carries the
Chromium signage Pi player, `apps/fc-divoom-dm-pi-device/` carries the additive
edge2 Divoom-as-DeviceManagement-device profile/Hiera contract, and
`apps/fc-divoom-tv-pi/` carries the Divoom TV Pi HDMI systemd/Puppet shape.
These bundles intentionally avoid Deployment, IngressRoute, Certificate, and
OnePasswordItem resources.
## References
- OpenVox noc1 durability runbook: `docs/runbooks/openvoxserver-quadlet-durability.md`

View File

@@ -1,448 +1,453 @@
# Authentik OIDC backend
# ArgoCD-managed. BlueJay Lab.
#
# Stack:
# - PostgreSQL 16 StatefulSet (single replica, Longhorn RWO 5Gi)
# - Redis 7 Deployment (no persistence — session/cache only)
# - Authentik server + worker Deployments (image ghcr.io/goauthentik/server:2024.12.3)
# - Media PVC shared between server + worker (Longhorn RWO 2Gi)
# - Certificate via step-ca-acme ClusterIssuer
# - Traefik IngressRoute at id.iamworkin.lan
#
# Secrets come from 1Password item "authentik-credentials" (IAmWorkin vault, id y6i74ch22q5wvm7znquq4nhhcu)
# via the OnePasswordItem CRD, materialized into k8s Secret authentik/authentik-credentials.
#
# Why the discovery URL is /application/o/pimanager/ : Authentik issues per-application OIDC providers.
# The pimanager OIDC application/provider is created after the cluster pods are healthy (manual or
# via API once the bootstrap token is available — see Notes substrate).
---
apiVersion: v1
kind: Namespace
metadata:
name: authentik
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# 1Password operator pulls the authentik-credentials item into a k8s Secret of the same name.
# Field labels in 1P become Secret keys: AUTHENTIK_SECRET_KEY, POSTGRES_PASSWORD, REDIS_PASSWORD,
# BOOTSTRAP_ADMIN_PASSWORD, BOOTSTRAP_ADMIN_TOKEN, BOOTSTRAP_ADMIN_EMAIL.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: authentik-credentials
namespace: authentik
spec:
itemPath: "vaults/IAmWorkin/items/authentik-credentials"
---
# Shared media volume for server + worker pods.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: authentik-media
namespace: authentik
spec:
storageClassName: longhorn
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 2Gi
---
# PostgreSQL 16 StatefulSet — Authentik's primary store.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: authentik-postgres
namespace: authentik
labels:
app: authentik-postgres
argocd.argoproj.io/instance: infra-authentik
spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
podManagementPolicy: OrderedReady
serviceName: authentik-postgres
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app: authentik-postgres
template:
metadata:
labels:
app: authentik-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
value: authentik
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
value: authentik
- name: POSTGRES_INITDB_ARGS
value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
readinessProbe:
exec:
command: ["pg_isready", "-U", "authentik"]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: ["pg_isready", "-U", "authentik"]
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1000m, memory: 1Gi }
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: pgdata
spec:
storageClassName: longhorn
accessModes: [ReadWriteOnce]
volumeMode: Filesystem
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: Service
metadata:
name: authentik-postgres
namespace: authentik
spec:
clusterIP: None
selector:
app: authentik-postgres
ports:
- name: postgres
port: 5432
targetPort: 5432
---
# Redis 7 — session storage + Celery broker. No persistence needed (cache).
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-redis
namespace: authentik
labels:
app: authentik-redis
argocd.argoproj.io/instance: infra-authentik
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: authentik-redis
template:
metadata:
labels:
app: authentik-redis
spec:
containers:
- name: redis
image: redis:7-alpine
args:
- "--save"
- ""
- "--appendonly"
- "no"
- "--requirepass"
- "$(REDIS_PASSWORD)"
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: REDIS_PASSWORD
ports:
- containerPort: 6379
name: redis
readinessProbe:
tcpSocket: { port: 6379 }
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
tcpSocket: { port: 6379 }
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 500m, memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata:
name: authentik-redis
namespace: authentik
spec:
selector:
app: authentik-redis
ports:
- name: redis
port: 6379
targetPort: 6379
---
# Authentik server Deployment — HTTP frontend on :9000.
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-server
namespace: authentik
labels:
app: authentik-server
argocd.argoproj.io/instance: infra-authentik
spec:
replicas: 1
strategy:
type: Recreate # shares /media RWO PVC with worker
selector:
matchLabels:
app: authentik-server
template:
metadata:
labels:
app: authentik-server
spec:
securityContext:
# Authentik image runs as uid 1000 "authentik" but the Longhorn PVC mounts
# root:root by default. fsGroup recursively chgrp + chmod g+rwx so the
# non-root container can mkdir /media/public during the tenant_files migration.
fsGroup: 1000
containers:
- name: server
image: ghcr.io/goauthentik/server:2024.12.3
args: ["server"]
ports:
- containerPort: 9000
name: http
- containerPort: 9443
name: https
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-credentials
key: AUTHENTIK_SECRET_KEY
- name: AUTHENTIK_REDIS__HOST
value: authentik-redis
- name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: REDIS_PASSWORD
- name: AUTHENTIK_POSTGRESQL__HOST
value: authentik-postgres
- name: AUTHENTIK_POSTGRESQL__NAME
value: authentik
- name: AUTHENTIK_POSTGRESQL__USER
value: authentik
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: POSTGRES_PASSWORD
- name: AUTHENTIK_BOOTSTRAP_PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: BOOTSTRAP_ADMIN_PASSWORD
- name: AUTHENTIK_BOOTSTRAP_TOKEN
valueFrom:
secretKeyRef:
name: authentik-credentials
key: BOOTSTRAP_ADMIN_TOKEN
- name: AUTHENTIK_BOOTSTRAP_EMAIL
valueFrom:
secretKeyRef:
name: authentik-credentials
key: BOOTSTRAP_ADMIN_EMAIL
- name: AUTHENTIK_DISABLE_UPDATE_CHECK
value: "true"
- name: AUTHENTIK_ERROR_REPORTING__ENABLED
value: "false"
- name: AUTHENTIK_LOG_LEVEL
value: info
# First-boot Authentik can take 3+ min on the migration phase
# (waiting on DB lock while worker also runs migrations). Initial
# delays are generous so kubelet doesn't kill the pod mid-migration;
# periodSeconds keeps post-startup probing responsive.
readinessProbe:
httpGet:
path: /-/health/ready/
port: 9000
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 12
livenessProbe:
httpGet:
path: /-/health/live/
port: 9000
initialDelaySeconds: 300
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
startupProbe:
httpGet:
path: /-/health/live/
port: 9000
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 40 # 30s + 40*15s = 10.5 min budget
resources:
requests: { cpu: 150m, memory: 512Mi }
limits: { cpu: 1500m, memory: 1Gi }
volumeMounts:
- name: media
mountPath: /media
volumes:
- name: media
persistentVolumeClaim:
claimName: authentik-media
---
# Authentik worker Deployment — runs Celery background tasks.
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-worker
namespace: authentik
labels:
app: authentik-worker
argocd.argoproj.io/instance: infra-authentik
spec:
replicas: 1
strategy:
type: Recreate # shares /media RWO PVC with server
selector:
matchLabels:
app: authentik-worker
template:
metadata:
labels:
app: authentik-worker
spec:
securityContext:
# Same as server pod — non-root uid 1000 needs PVC group write.
fsGroup: 1000
containers:
- name: worker
image: ghcr.io/goauthentik/server:2024.12.3
args: ["worker"]
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-credentials
key: AUTHENTIK_SECRET_KEY
- name: AUTHENTIK_REDIS__HOST
value: authentik-redis
- name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: REDIS_PASSWORD
- name: AUTHENTIK_POSTGRESQL__HOST
value: authentik-postgres
- name: AUTHENTIK_POSTGRESQL__NAME
value: authentik
- name: AUTHENTIK_POSTGRESQL__USER
value: authentik
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: POSTGRES_PASSWORD
- name: AUTHENTIK_DISABLE_UPDATE_CHECK
value: "true"
- name: AUTHENTIK_ERROR_REPORTING__ENABLED
value: "false"
- name: AUTHENTIK_LOG_LEVEL
value: info
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1000m, memory: 768Mi }
volumeMounts:
- name: media
mountPath: /media
volumes:
- name: media
persistentVolumeClaim:
claimName: authentik-media
---
apiVersion: v1
kind: Service
metadata:
name: authentik-server
namespace: authentik
spec:
selector:
app: authentik-server
ports:
- name: http
port: 9000
targetPort: 9000
- name: https
port: 9443
targetPort: 9443
---
# step-ca leaf certificate for id.iamworkin.lan.
# step-ca container resolver uses pfSense Unbound, so the public A record for id.iamworkin.lan
# MUST exist before this Certificate is applied (cert-manager HTTP-01 will silently 2h-backoff
# otherwise). Added 2026-05-25 via scripts/pfsense-add-id-host.py.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: authentik-tls
namespace: authentik
spec:
secretName: authentik-tls
dnsNames:
- id.iamworkin.lan
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: authentik
namespace: authentik
spec:
entryPoints: [websecure]
routes:
- match: Host(`id.iamworkin.lan`)
kind: Rule
services:
- name: authentik-server
port: 9000
tls:
secretName: authentik-tls
# Authentik OIDC backend
# ArgoCD-managed. BlueJay Lab.
#
# Stack:
# - PostgreSQL 16 StatefulSet (single replica, Longhorn RWO 5Gi)
# - Redis 7 Deployment (no persistence — session/cache only)
# - Authentik server + worker Deployments (image ghcr.io/goauthentik/server:2024.12.3)
# - Media PVC shared between server + worker (Longhorn RWO 2Gi)
# - Certificate via step-ca-acme ClusterIssuer
# - Traefik IngressRoute at id.iamworkin.lan
#
# Secrets come from 1Password item "authentik-credentials" (IAmWorkin vault, id y6i74ch22q5wvm7znquq4nhhcu)
# via the OnePasswordItem CRD, materialized into k8s Secret authentik/authentik-credentials.
#
# Why the discovery URL is /application/o/pimanager/ : Authentik issues per-application OIDC providers.
# The pimanager OIDC application/provider is created after the cluster pods are healthy (manual or
# via API once the bootstrap token is available — see Notes substrate).
---
apiVersion: v1
kind: Namespace
metadata:
name: authentik
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# 1Password operator pulls the authentik-credentials item into a k8s Secret of the same name.
# Field labels in 1P become Secret keys: AUTHENTIK_SECRET_KEY, POSTGRES_PASSWORD, REDIS_PASSWORD,
# BOOTSTRAP_ADMIN_PASSWORD, BOOTSTRAP_ADMIN_TOKEN, BOOTSTRAP_ADMIN_EMAIL.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: authentik-credentials
namespace: authentik
spec:
itemPath: "vaults/IAmWorkin/items/authentik-credentials"
---
# Shared media volume for server + worker pods.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: authentik-media
namespace: authentik
spec:
storageClassName: longhorn
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 2Gi
---
# PostgreSQL 16 StatefulSet — Authentik's primary store.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: authentik-postgres
namespace: authentik
labels:
app: authentik-postgres
argocd.argoproj.io/instance: infra-authentik
spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
podManagementPolicy: OrderedReady
serviceName: authentik-postgres
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app: authentik-postgres
template:
metadata:
labels:
app: authentik-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
value: authentik
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
value: authentik
- name: POSTGRES_INITDB_ARGS
value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
readinessProbe:
exec:
command: ["pg_isready", "-U", "authentik"]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: ["pg_isready", "-U", "authentik"]
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1000m, memory: 1Gi }
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
# apiVersion/kind included deliberately: this STS was created via ArgoCD ServerSideApply,
# so the live object carries PVC TypeMeta inside volumeClaimTemplates; omitting it here
# leaves the app eternally OutOfSync even though kubectl SSA dry-run shows no change.
- apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pgdata
spec:
storageClassName: longhorn
accessModes: [ReadWriteOnce]
volumeMode: Filesystem
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: Service
metadata:
name: authentik-postgres
namespace: authentik
spec:
clusterIP: None
selector:
app: authentik-postgres
ports:
- name: postgres
port: 5432
targetPort: 5432
---
# Redis 7 — session storage + Celery broker. No persistence needed (cache).
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-redis
namespace: authentik
labels:
app: authentik-redis
argocd.argoproj.io/instance: infra-authentik
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: authentik-redis
template:
metadata:
labels:
app: authentik-redis
spec:
containers:
- name: redis
image: redis:7-alpine
args:
- "--save"
- ""
- "--appendonly"
- "no"
- "--requirepass"
- "$(REDIS_PASSWORD)"
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: REDIS_PASSWORD
ports:
- containerPort: 6379
name: redis
readinessProbe:
tcpSocket: { port: 6379 }
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
tcpSocket: { port: 6379 }
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 500m, memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata:
name: authentik-redis
namespace: authentik
spec:
selector:
app: authentik-redis
ports:
- name: redis
port: 6379
targetPort: 6379
---
# Authentik server Deployment — HTTP frontend on :9000.
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-server
namespace: authentik
labels:
app: authentik-server
argocd.argoproj.io/instance: infra-authentik
spec:
replicas: 1
strategy:
type: Recreate # shares /media RWO PVC with worker
selector:
matchLabels:
app: authentik-server
template:
metadata:
labels:
app: authentik-server
spec:
securityContext:
# Authentik image runs as uid 1000 "authentik" but the Longhorn PVC mounts
# root:root by default. fsGroup recursively chgrp + chmod g+rwx so the
# non-root container can mkdir /media/public during the tenant_files migration.
fsGroup: 1000
containers:
- name: server
image: ghcr.io/goauthentik/server:2024.12.3
args: ["server"]
ports:
- containerPort: 9000
name: http
- containerPort: 9443
name: https
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-credentials
key: AUTHENTIK_SECRET_KEY
- name: AUTHENTIK_REDIS__HOST
value: authentik-redis
- name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: REDIS_PASSWORD
- name: AUTHENTIK_POSTGRESQL__HOST
value: authentik-postgres
- name: AUTHENTIK_POSTGRESQL__NAME
value: authentik
- name: AUTHENTIK_POSTGRESQL__USER
value: authentik
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: POSTGRES_PASSWORD
- name: AUTHENTIK_BOOTSTRAP_PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: BOOTSTRAP_ADMIN_PASSWORD
- name: AUTHENTIK_BOOTSTRAP_TOKEN
valueFrom:
secretKeyRef:
name: authentik-credentials
key: BOOTSTRAP_ADMIN_TOKEN
- name: AUTHENTIK_BOOTSTRAP_EMAIL
valueFrom:
secretKeyRef:
name: authentik-credentials
key: BOOTSTRAP_ADMIN_EMAIL
- name: AUTHENTIK_DISABLE_UPDATE_CHECK
value: "true"
- name: AUTHENTIK_ERROR_REPORTING__ENABLED
value: "false"
- name: AUTHENTIK_LOG_LEVEL
value: info
# First-boot Authentik can take 3+ min on the migration phase
# (waiting on DB lock while worker also runs migrations). Initial
# delays are generous so kubelet doesn't kill the pod mid-migration;
# periodSeconds keeps post-startup probing responsive.
readinessProbe:
httpGet:
path: /-/health/ready/
port: 9000
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 12
livenessProbe:
httpGet:
path: /-/health/live/
port: 9000
initialDelaySeconds: 300
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
startupProbe:
httpGet:
path: /-/health/live/
port: 9000
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 10
failureThreshold: 40 # 30s + 40*15s = 10.5 min budget
resources:
requests: { cpu: 150m, memory: 512Mi }
limits: { cpu: 1500m, memory: 1Gi }
volumeMounts:
- name: media
mountPath: /media
volumes:
- name: media
persistentVolumeClaim:
claimName: authentik-media
---
# Authentik worker Deployment — runs Celery background tasks.
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-worker
namespace: authentik
labels:
app: authentik-worker
argocd.argoproj.io/instance: infra-authentik
spec:
replicas: 1
strategy:
type: Recreate # shares /media RWO PVC with server
selector:
matchLabels:
app: authentik-worker
template:
metadata:
labels:
app: authentik-worker
spec:
securityContext:
# Same as server pod — non-root uid 1000 needs PVC group write.
fsGroup: 1000
containers:
- name: worker
image: ghcr.io/goauthentik/server:2024.12.3
args: ["worker"]
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: authentik-credentials
key: AUTHENTIK_SECRET_KEY
- name: AUTHENTIK_REDIS__HOST
value: authentik-redis
- name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: REDIS_PASSWORD
- name: AUTHENTIK_POSTGRESQL__HOST
value: authentik-postgres
- name: AUTHENTIK_POSTGRESQL__NAME
value: authentik
- name: AUTHENTIK_POSTGRESQL__USER
value: authentik
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-credentials
key: POSTGRES_PASSWORD
- name: AUTHENTIK_DISABLE_UPDATE_CHECK
value: "true"
- name: AUTHENTIK_ERROR_REPORTING__ENABLED
value: "false"
- name: AUTHENTIK_LOG_LEVEL
value: info
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1000m, memory: 768Mi }
volumeMounts:
- name: media
mountPath: /media
volumes:
- name: media
persistentVolumeClaim:
claimName: authentik-media
---
apiVersion: v1
kind: Service
metadata:
name: authentik-server
namespace: authentik
spec:
selector:
app: authentik-server
ports:
- name: http
port: 9000
targetPort: 9000
- name: https
port: 9443
targetPort: 9443
---
# step-ca leaf certificate for id.iamworkin.lan.
# step-ca container resolver uses pfSense Unbound, so the public A record for id.iamworkin.lan
# MUST exist before this Certificate is applied (cert-manager HTTP-01 will silently 2h-backoff
# otherwise). Added 2026-05-25 via scripts/pfsense-add-id-host.py.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: authentik-tls
namespace: authentik
spec:
secretName: authentik-tls
dnsNames:
- id.iamworkin.lan
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: authentik
namespace: authentik
spec:
entryPoints: [websecure]
routes:
- match: Host(`id.iamworkin.lan`)
kind: Rule
services:
- name: authentik-server
port: 9000
tls:
secretName: authentik-tls

View File

@@ -0,0 +1,195 @@
# FlowerCore.AiStation.Web GitOps adoption manifest.
#
# Authored from the already-live fc-aistation resources on 2026-06-04.
# Keep the live image tag, Service ClusterIP, and PVC volumeName unchanged so
# ArgoCD adopts in place instead of replacing the workload or data volume.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: aistation-web-data
namespace: fc-aistation
labels:
app.kubernetes.io/name: aistation-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-aistation
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: longhorn
volumeMode: Filesystem
volumeName: pvc-27448d6f-6e66-42a7-a293-73dd8bbd6b3e
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: aistation-web
namespace: fc-aistation
labels:
app.kubernetes.io/name: aistation-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-aistation
spec:
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/name: aistation-web
strategy:
type: Recreate
template:
metadata:
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/path: /metrics/prometheus
prometheus.io/port: "5000"
prometheus.io/scrape: "true"
labels:
app.kubernetes.io/name: aistation-web
app.kubernetes.io/part-of: flowercore
spec:
containers:
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
- envFrom:
- configMapRef:
name: aistation-web-config
image: localhost/fc-aistation-web:v20260602-aistation-owned-deploy-fix2
imagePullPolicy: Never
livenessProbe:
failureThreshold: 3
httpGet:
path: /healthz
port: 5000
scheme: HTTP
initialDelaySeconds: 30
periodSeconds: 30
successThreshold: 1
timeoutSeconds: 5
name: aistation-web
ports:
- containerPort: 5000
name: http
protocol: TCP
readinessProbe:
failureThreshold: 6
httpGet:
path: /healthz
port: 5000
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /data
name: data
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: aistation-web-data
---
apiVersion: v1
kind: Service
metadata:
name: aistation-web
namespace: fc-aistation
labels:
app.kubernetes.io/name: aistation-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-aistation
spec:
clusterIP: 10.43.211.127
clusterIPs:
- 10.43.211.127
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 80
protocol: TCP
targetPort: 5000
selector:
app.kubernetes.io/name: aistation-web
sessionAffinity: None
type: ClusterIP
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: aistation-web-tls
namespace: fc-aistation
labels:
app.kubernetes.io/name: aistation-web-tls
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-aistation
spec:
dnsNames:
- aistation.iamworkin.lan
issuerRef:
kind: ClusterIssuer
name: step-ca-acme
secretName: aistation-web-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: aistation-web
namespace: fc-aistation
labels:
app.kubernetes.io/name: aistation-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-aistation
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`aistation.iamworkin.lan`)
services:
- name: aistation-web
port: 80
tls:
secretName: aistation-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose aistation-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: aistation-web-public
# namespace: fc-aistation
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`aistation.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: aistation-web-public-profile-header # injects entitlement profile
# services:
# - name: aistation-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -1,5 +1,207 @@
# FlowerCore Chat — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
# FlowerCore Chat
#
# ArgoCD-managed workload plus TLS/Ingress. The chat-web-secret remains an
# out-of-band Secret until the values are moved into a 1Password-backed item;
# the Deployment references it as optional so GitOps can own the workload
# without storing secret material in this repo.
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-chat
labels:
app.kubernetes.io/part-of: flowercore
---
apiVersion: v1
kind: ConfigMap
metadata:
name: chat-web-config
namespace: fc-chat
labels:
app.kubernetes.io/name: chat-web
app.kubernetes.io/part-of: flowercore
data:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: "http://+:8080"
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
FlowerCore__Auth__Enabled: "false"
FlowerCore__Auth__Oidc__Enabled: "true"
FlowerCore__Auth__Oidc__Authority: "https://id.iamworkin.lan/application/o/chat/"
FlowerCore__Auth__Oidc__Audience: "chat"
FlowerCore__Auth__Oidc__ClientId: "chat"
FlowerCore__Database__ConnectionStrings__Sqlite: "Data Source=/data/chat.db"
# Ollama target. BLUEJAY-WS remains faster from the workstation, but this lane
# proved Chat pods time out reaching 10.0.56.20:11434. Keep generation and
# behavior-rule checks on the cluster-routable edge1 endpoint until that route
# is fixed; choose models that edge1 actually hosts.
FlowerCore__AI__OllamaBaseUrl: "http://10.0.57.17:11434"
FlowerCore__AI__DefaultModelName: "qwen2.5-coder:7b"
ChatOptions__BehaviorRuleEngine__OllamaBaseUrl: "http://10.0.57.17:11434"
ChatOptions__BehaviorRuleEngine__FallbackOllamaBaseUrl: "http://10.0.57.17:11434"
ChatOptions__BehaviorRuleEngine__ModelName: "gemma3:4b"
FlowerCore__AI__Memory__UseSharedIndexingAdapter: "true"
FlowerCore__AI__Memory__UseOllamaEmbeddings: "true"
FlowerCore__AI__Memory__EmbeddingModel: "nomic-embed-text"
FlowerCore__AI__Memory__EnableSharedIndexingBackfill: "true"
FlowerCore__AI__Memory__SharedIndexingDatabasePath: "/data/chat-memory-index.db"
FlowerCore__AI__Skills__Library__LibraryApiUrl: "http://library-web.fc-library.svc.cluster.local"
FlowerCore__AI__Skills__Retail__RetailApiUrl: "http://retail-web.fc-retail.svc.cluster.local"
FlowerCore__AI__Skills__Intranet__IntranetBaseUrl: "http://intranet-web.intranet.svc.cluster.local"
FlowerCore__AI__Skills__Print__PrintMcpBaseUrl: "http://10.0.57.16:5200"
FlowerCore__AI__Helpdesk__SentimentEscalation__Enabled: "true"
FlowerCore__AI__IrcBridge__Enabled: "true"
FlowerCore__AI__IrcBridge__DefaultProfileSlug: "it-helpdesk"
FlowerCore__AI__IrcBridge__MentionProfileSlug: "it-helpdesk"
FlowerCore__AI__IrcBridge__MentionReactiveMode: "mentions-only"
FlowerCore__AI__IrcBridge__AllowActionExecution: "false"
FlowerCore__AI__Voice__Piper__Host: "10.0.57.17"
FlowerCore__AI__Voice__Piper__Port: "10400"
FlowerCore__AI__Voice__OutputRoot: "/data/audio"
FlowerCore__AI__Voice__RetentionDays: "30"
# LLM provider abstraction (ADR-088). Anthropic stays disabled here -- when
# an operator wants to enable Claude, they flip Enabled=true and mount
# FlowerCore__Anthropic__ApiKey from the onepassword-synced Secret (see
# docs/ai-agents/anthropic-integration.md).
FlowerCore__Anthropic__Enabled: "false"
FlowerCore__Anthropic__BaseUrl: "https://api.anthropic.com"
FlowerCore__Anthropic__DefaultModel: "claude-sonnet-4-6"
FlowerCore__Anthropic__CheapModel: "claude-haiku-4-5-20251001"
FlowerCore__Anthropic__DeepModel: "claude-opus-4-7"
FlowerCore__Budget__ResponseCacheEnabled: "true"
OTEL_SERVICE_NAME: FlowerCore.Chat
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.monitoring.svc.cluster.local:4317"
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: chat-web-data
namespace: fc-chat
labels:
app.kubernetes.io/name: chat-web
app.kubernetes.io/part-of: flowercore
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
volumeMode: Filesystem
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: chat-web
namespace: fc-chat
labels:
app.kubernetes.io/name: chat-web
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: chat-web
template:
metadata:
labels:
app.kubernetes.io/name: chat-web
app.kubernetes.io/part-of: flowercore
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics/prometheus"
spec:
nodeSelector:
kubernetes.io/hostname: rke2-server
securityContext:
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: chat-web
image: localhost/fc-chat-web:v20260614-wave5-sentiment-685f62c
imagePullPolicy: Never
ports:
- name: http
containerPort: 8080
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
envFrom:
- configMapRef:
name: chat-web-config
- secretRef:
name: chat-web-secret
optional: true
env:
- name: FlowerCore__Auth__Oidc__Authority
valueFrom:
secretKeyRef:
name: chat-oidc-client
key: issuer_url
optional: true
- name: FlowerCore__Auth__Oidc__ClientId
valueFrom:
secretKeyRef:
name: chat-oidc-client
key: client_id
optional: true
- name: FlowerCore__Auth__Oidc__ClientSecret
valueFrom:
secretKeyRef:
name: chat-oidc-client
key: client_secret
optional: true
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: data
persistentVolumeClaim:
claimName: chat-web-data
---
apiVersion: v1
kind: Service
metadata:
name: chat-web
namespace: fc-chat
labels:
app.kubernetes.io/name: chat-web
app.kubernetes.io/part-of: flowercore
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: chat-web
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
---
apiVersion: cert-manager.io/v1
kind: Certificate

View File

@@ -14,6 +14,20 @@
# cluster-rebuild repeatability. See
# feedback_networkpolicies_belong_in_bluejay_infra.md.
---
# OIDC client secret for the RemoteDesktop end-user sign-in (fleet regroup L9,
# 2026-06-12). The Authentik provider `remotedesktop` already exists; the 1P item
# `remotedesktop-oidc-client` (vault IAmWorkin) carries issuer_url / client_id /
# client_secret, and the 1Password operator mints the same-named K8s Secret that
# k8s/web-deployment.yaml (FlowerCore.RemoteDesktop repo) consumes with
# optional:true. Gate stays OFF (Q-RD-16) — this is flip-READINESS only.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: remotedesktop-oidc-client
namespace: fc-desktop
spec:
itemPath: "vaults/IAmWorkin/items/remotedesktop-oidc-client"
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
@@ -51,3 +65,26 @@ spec:
port: 8080
tls:
secretName: remotedesktop-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose remotedesktop-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: remotedesktop-web-public
# namespace: fc-desktop
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`desktop.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: remotedesktop-web-public-profile-header # injects entitlement profile
# services:
# - name: remotedesktop-web
# port: 8080
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -11,7 +11,7 @@ metadata:
flowercore.io/created-by: bluejay-infra
rules:
- apiGroups:
- devices.flowercore.io
- flowercore.io
resources:
- '*'
verbs:
@@ -23,7 +23,7 @@ rules:
- patch
- delete
- apiGroups:
- devices.flowercore.io
- flowercore.io
resources:
- devices/status
- devices/finalizers
@@ -33,6 +33,8 @@ rules:
- devicepolicies/finalizers
- remotecommands/status
- remotecommands/finalizers
- desiredstatedocuments/status
- desiredstatedocuments/finalizers
verbs:
- get
- update

View File

@@ -0,0 +1,186 @@
# FlowerCore.DeviceManagement CRDs.
#
# These CRDs match the current operator annotations:
# [KubernetesEntity(Group = "flowercore.io", ApiVersion = "v1alpha1", ...)]
# Keep the schemas intentionally permissive until the DeviceManagement operator
# grows enforced CRD validation.
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: devices.flowercore.io
labels:
app.kubernetes.io/name: fc-devicemgmt-operator
app.kubernetes.io/component: operator
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
group: flowercore.io
scope: Namespaced
names:
plural: devices
singular: device
kind: Device
listKind: DeviceList
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
x-kubernetes-preserve-unknown-fields: true
status:
type: object
x-kubernetes-preserve-unknown-fields: true
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: devicegroups.flowercore.io
labels:
app.kubernetes.io/name: fc-devicemgmt-operator
app.kubernetes.io/component: operator
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
group: flowercore.io
scope: Namespaced
names:
plural: devicegroups
singular: devicegroup
kind: DeviceGroup
listKind: DeviceGroupList
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
x-kubernetes-preserve-unknown-fields: true
status:
type: object
x-kubernetes-preserve-unknown-fields: true
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: devicepolicies.flowercore.io
labels:
app.kubernetes.io/name: fc-devicemgmt-operator
app.kubernetes.io/component: operator
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
group: flowercore.io
scope: Namespaced
names:
plural: devicepolicies
singular: devicepolicy
kind: DevicePolicy
listKind: DevicePolicyList
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
x-kubernetes-preserve-unknown-fields: true
status:
type: object
x-kubernetes-preserve-unknown-fields: true
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: remotecommands.flowercore.io
labels:
app.kubernetes.io/name: fc-devicemgmt-operator
app.kubernetes.io/component: operator
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
group: flowercore.io
scope: Namespaced
names:
plural: remotecommands
singular: remotecommand
kind: RemoteCommand
listKind: RemoteCommandList
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
x-kubernetes-preserve-unknown-fields: true
status:
type: object
x-kubernetes-preserve-unknown-fields: true
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: desiredstatedocuments.flowercore.io
labels:
app.kubernetes.io/name: fc-devicemgmt-operator
app.kubernetes.io/component: operator
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
group: flowercore.io
scope: Namespaced
names:
plural: desiredstatedocuments
singular: desiredstatedocument
kind: DesiredStateDocument
listKind: DesiredStateDocumentList
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
x-kubernetes-preserve-unknown-fields: true
status:
type: object
x-kubernetes-preserve-unknown-fields: true

View File

@@ -5,21 +5,35 @@
# exist yet; import localhost/fc-devicemgmt-web:<tag> to all schedulable RKE2
# nodes before letting ArgoCD sync a live rollout.
#
# SCALED TO 0 — 2026-05-19 morning-routine cleanup.
# The Web pod cannot start until TWO upstream gaps close:
# 1. MySQL DB instance `flowercore_devicemgmt` (user `fc_devicemgmt`) is
# provisioned via fc-mysql Manager. The cluster currently has ZERO
# MySqlInstanceCrds and no `mysql.fc-mysql.svc:3306` Service, so the
# deployment-web container env `FlowerCore__Database__Host=mysql.fc-mysql.svc`
# points at nothing. Provision via the fc-mysql Manager UI/REST/MCP.
# 2. 1Password vault item `IAmWorkin/FlowerCore DeviceManagement Runtime`
# with 5 fields (DB-Password, mtls-ca.pem, mtls-client.crt, mtls-client.key,
# mtls-chain.pem) — see apps/fc-devicemgmt/1password-item.yaml. Mint mTLS
# from step-ca-agent ClusterIssuer per ADR-126; DB-Password must match the
# password configured for the MySQL user.
# Re-enable: change replicas back to 2 after both gaps close. The image tag
# in this file (v20260512-cx5) MAY also need a refresh — it predates the
# Sprint 34 Cl-3 operator fix; Web may have an analogous bug.
# LIVE — 2026-06-11 DeviceManagement product-host enablement.
# The current DeviceManagement Web source is SQLite-backed in Program.cs, so
# Phase 1 production uses a Longhorn RWO PVC at /data/devicemgmt.db. The
# 1Password runtime item stays mounted through env for future MySQL/API-key
# cutover, but MySQL is not required for this first product-host rollout.
# Image v20260613-g2-66a43c1 is built from FlowerCore.DeviceManagement master
# 66a43c1, carrying edge enrollment network completion and SQLite-safe trust-bundle smoke coverage.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fc-devicemgmt-web-data
namespace: fc-devicemgmt
labels:
app: fc-devicemgmt-web
app.kubernetes.io/name: fc-devicemgmt-web
app.kubernetes.io/component: web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -36,8 +50,13 @@ metadata:
annotations:
flowercore.io/traceability-standard: k8s-pod-ownership-and-traceability-standard
spec:
replicas: 0
replicas: 1
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
selector:
matchLabels:
app: fc-devicemgmt-web
@@ -52,6 +71,8 @@ spec:
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
@@ -62,11 +83,12 @@ spec:
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
image: localhost/fc-devicemgmt-web:v20260512-cx5
image: localhost/fc-devicemgmt-web:v20260613-g3-6555c0d
imagePullPolicy: Never
ports:
- name: http
containerPort: 8080
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
@@ -74,29 +96,21 @@ spec:
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
- name: HOME
value: "/data"
- name: FlowerCore__Service__Name
value: "FlowerCore.DeviceManagement.Web"
- name: FlowerCore__DeviceManagement__DefaultTenantId
value: "system"
- name: FlowerCore__Database__Provider
value: "MySql"
- name: FlowerCore__Database__Host
value: "mysql.fc-mysql.svc"
- name: FlowerCore__Database__Database
value: "flowercore_devicemgmt"
- name: FlowerCore__Database__User
value: "fc_devicemgmt"
value: "Sqlite"
- name: FlowerCore__Database__ConnectionStrings__Sqlite
value: "Data Source=/data/devicemgmt.db"
- name: FlowerCore__Database__Password
valueFrom:
secretKeyRef:
name: fc-devicemgmt-runtime
key: DB-Password
- name: FlowerCore__DeviceManagement__AgentMtls__CaPath
value: "/secrets/devicemgmt-mtls/mtls-ca.pem"
- name: FlowerCore__DeviceManagement__AgentMtls__ClientCertificatePath
value: "/secrets/devicemgmt-mtls/mtls-client.crt"
- name: FlowerCore__DeviceManagement__AgentMtls__ClientKeyPath
value: "/secrets/devicemgmt-mtls/mtls-client.key"
- name: FlowerCore__EventBus__Redis__Configuration
value: "redis.fc-redis.svc:6379"
resources:
@@ -133,19 +147,17 @@ spec:
drop:
- ALL
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
- name: devicemgmt-mtls
mountPath: /secrets/devicemgmt-mtls
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: fc-devicemgmt-web-data
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
- name: devicemgmt-mtls
secret:
secretName: fc-devicemgmt-runtime
defaultMode: 0400

View File

@@ -74,6 +74,14 @@ metadata:
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Edition Signing Key - edition:aistation-field"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: distribution-oidc-client
namespace: fc-distribution
spec:
itemPath: "vaults/IAmWorkin/items/distribution-oidc-client"
---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -101,6 +109,7 @@ spec:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec:
# Synology NFS export `/volume1/kubernetes` ACL only allows rke2-server
# (10.0.56.11) right now. Until the ACL is widened in DSM (admin only),
@@ -118,7 +127,7 @@ spec:
# dotnet.exe publish -c Release -o deploy/app \
# src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
# podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy
image: localhost/fc-distribution:v202605061948
image: localhost/fc-distribution:v20260604-oidc-root-anon
imagePullPolicy: Never
ports:
- containerPort: 8080
@@ -130,6 +139,25 @@ spec:
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
# Authentik/OIDC enforcement. Public read/entitlement + the
# dist.flowercore.io Method() allowlist stay open; OIDC gates the
# operator/admin surface while /healthz remains anonymous.
- name: FlowerCore__Auth__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Authority
value: "https://id.iamworkin.lan/application/o/distribution/"
- name: FlowerCore__Auth__Oidc__Audience
value: "distribution"
- name: FlowerCore__Auth__Oidc__ClientId
value: "distribution"
- name: FlowerCore__Auth__Oidc__ClientSecret
valueFrom:
secretKeyRef:
name: distribution-oidc-client
key: client_secret
optional: true
# SQLite connection (catalog + data-protection keys via FlowerCoreDbContext).
# Read by Data/DatabaseProviderExtensions.cs in precedence order; Sqlite key wins.
- name: FlowerCore__Database__Provider

View File

@@ -0,0 +1,45 @@
# FlowerCore Divoom DM Pi Device
Source-controlled Puppet/Hiera deployment contract for registering the edge2
Divoom MiniToo panel as a FlowerCore DeviceManagement-managed Pi device.
This is not a Kubernetes application. The live panel remains the existing
edge2 `flowercore-divoom.service` managed by `FlowerCore.Puppet`
`profile::pi::service::divoom`, with the .NET payload deployed out of band
and `/opt/flowercore/divoom/data` plus the Bluetooth shell wrappers preserved.
Because edge2 is already Hiera-driven through `profile::pi::service::apps`,
the deploy home is additive `profile::pi::service` data/profile source, not
`profile::edge::service::apps` and not an ArgoCD/K8s app.
## Scope
- Stage DeviceManagement registration metadata for the edge2 Divoom MiniToo.
- Stage a separate, disabled-by-default DM Agent executor unit for privileged
Bluetooth operations once the DM-RPC lane lands.
- Keep `flowercore-divoom.service` and `flowercore-divoom-bt.service`
untouched: no service replacement, no restart subscription, no K8s surface.
- Preserve the current wrapper contract:
`/opt/flowercore/divoom/bt-link.sh`,
`/opt/flowercore/divoom/bt-reset.sh`, and
`/opt/flowercore/divoom/audio-link.sh`.
- Keep FM radio disabled and require visible render proof; device-info echo is
not render proof.
## Artifact Map
| Path | Use |
| --- | --- |
| `hiera/edge2-divoom-dm-device.overlay.yaml` | Additive Hiera overlay for edge2. Merge into the existing node YAML without removing `fc-pimanager` or `fc-divoom`. |
| `puppet/profile/pi/service/divoom_dm_device.pp` | Puppet profile shape to vendor into `FlowerCore.Puppet` after the DM-RPC executor binary exists. |
| `puppet/templates/divoom-device-registration.json.epp` | DM device registration metadata rendered on edge2. |
| `puppet/templates/flowercore-divoom-dm-agent.service.epp` | Separate DM Agent systemd unit. Defaults are stopped and disabled until a later cutover. |
## Rollout Notes
1. Land these artifacts in bluejay-infra as the deploy contract.
2. Vendor the Puppet profile and EPP templates into `FlowerCore.Puppet`.
3. Merge the Hiera overlay into `data/nodes/edge2.iamworkin.lan.yaml`.
4. Run Puppet in noop first, preferably with a node-local validation directory
under `~/.fcv` rather than `/tmp`.
5. Only enable the DM Agent service after the DeviceManagement BT executor has
landed and passed operator-eyeball render proof.

View File

@@ -0,0 +1,32 @@
---
# Merge into FlowerCore.Puppet data/nodes/edge2.iamworkin.lan.yaml.
# Additive overlay only: keep the existing fc-pimanager version/tarball entry,
# keep fc-divoom enabled, and do not move Divoom into Kubernetes.
profile::pi::service::apps:
fc-pimanager:
binary: 'FlowerCore.PiManager.Web'
install_dir: '/opt/fc-pimanager'
port: 5000
environment: 'edge2'
version: '2026.05.28.1646'
tarball_source: 'puppet:///modules/profile/pi/builds/fc-pimanager.tar.gz'
fc-divoom:
enabled: true
profile::pi::service::divoom_dm_device::ensure: 'present'
profile::pi::service::divoom_dm_device::service_enabled: false
profile::pi::service::divoom_dm_device::service_ensure: 'stopped'
profile::pi::service::divoom_dm_device::device_id: 'edge2-divoom-minitoo'
profile::pi::service::divoom_dm_device::display_name: 'edge2 Divoom MiniToo'
profile::pi::service::divoom_dm_device::host_fqdn: 'edge2.iamworkin.lan'
profile::pi::service::divoom_dm_device::dm_web_url: 'https://devicemgmt.iamworkin.lan'
profile::pi::service::divoom_dm_device::divoom_install_dir: '/opt/flowercore/divoom'
profile::pi::service::divoom_dm_device::agent_install_dir: '/opt/flowercore/devicemanagement-agent'
profile::pi::service::divoom_dm_device::bt_candidate_channels:
- '1'
- '10'
profile::pi::service::divoom_dm_device::default_bt_channel: '1'
profile::pi::service::divoom_dm_device::a2dp_default_state: 'off'
profile::pi::service::divoom_dm_device::fm_radio_enabled: false
profile::pi::service::divoom_dm_device::visible_render_proof_required: true

View File

@@ -0,0 +1,140 @@
# Drop into FlowerCore.Puppet site-modules/profile/manifests/pi/service/divoom_dm_device.pp.
# This profile is additive to profile::pi::service::divoom. It must not manage,
# restart, replace, or subscribe the existing flowercore-divoom.service.
class profile::pi::service::divoom_dm_device (
Enum['present', 'absent'] $ensure = 'present',
Boolean $service_enabled = false,
Enum['running', 'stopped'] $service_ensure = 'stopped',
String $service_name = 'flowercore-divoom-dm-agent',
String $device_id = 'edge2-divoom-minitoo',
String $display_name = 'edge2 Divoom MiniToo',
String $host_fqdn = 'edge2.iamworkin.lan',
String $dm_web_url = 'https://devicemgmt.iamworkin.lan',
String $divoom_install_dir = '/opt/flowercore/divoom',
String $agent_install_dir = '/opt/flowercore/devicemanagement-agent',
String $agent_binary = 'FlowerCore.DeviceManagement.Agent',
Array[String] $bt_candidate_channels = ['1', '10'],
String $default_bt_channel = '1',
Enum['on', 'off'] $a2dp_default_state = 'off',
Boolean $fm_radio_enabled = false,
Boolean $visible_render_proof_required = true,
) {
include profile::workstation::safe_account_exclusion
$safe_account = $profile::workstation::safe_account_exclusion::safe_account
$config_dir = '/etc/flowercore/device-management/devices'
$state_dir = '/var/lib/flowercore/divoom-dm-agent'
$log_dir = '/var/log/flowercore/divoom-dm-agent'
$registration_path = "${config_dir}/${device_id}.json"
$agent_binary_path = "${agent_install_dir}/${agent_binary}"
$bt_channels_json = inline_template('[<%= @bt_candidate_channels.map { |c| "\"#{c}\"" }.join(", ") %>]')
if $safe_account {
notify { 'fc-divoom-dm-device safe-account exclusion':
message => 'SAFE-ACCOUNT-EXCLUSION: Divoom DM Pi device profile refused to apply on operator workstation',
}
if $facts['os']['family'] != 'windows' {
ensure_resource('file', '/var/log/flowercore-audit', {
'ensure' => 'directory',
'owner' => 'root',
'group' => 'root',
'mode' => '0755',
})
file { '/var/log/flowercore-audit/safe-account-noop-fc-divoom-dm-device.log':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => "noop: divoom dm pi device profile refused to apply on safe-account host\n",
require => File['/var/log/flowercore-audit'],
}
}
} elsif $ensure == 'absent' {
service { $service_name:
ensure => stopped,
enable => false,
}
file { [
"/etc/systemd/system/${service_name}.service",
$registration_path,
]:
ensure => absent,
}
exec { 'fc-divoom-dm-agent-systemd-reload':
command => '/usr/bin/systemctl daemon-reload',
refreshonly => true,
path => ['/usr/bin', '/bin'],
}
} else {
case $facts['os']['family'] {
'Debian': {}
default: { fail("profile::pi::service::divoom_dm_device only supports Debian-family OS, got ${facts['os']['family']}") }
}
file { [$config_dir, $state_dir, $log_dir]:
ensure => directory,
owner => 'root',
group => 'root',
mode => '0755',
}
file { $registration_path:
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => epp('profile/pi/fc_divoom_dm/divoom-device-registration.json.epp', {
'device_id' => $device_id,
'display_name' => $display_name,
'host_fqdn' => $host_fqdn,
'divoom_install_dir' => $divoom_install_dir,
'bt_channels_json' => $bt_channels_json,
'default_bt_channel' => $default_bt_channel,
'a2dp_default_state' => $a2dp_default_state,
'fm_radio_enabled' => $fm_radio_enabled,
'visible_render_proof_required' => $visible_render_proof_required,
}),
require => File[$config_dir],
}
file { "/etc/systemd/system/${service_name}.service":
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => epp('profile/pi/fc_divoom_dm/flowercore-divoom-dm-agent.service.epp', {
'service_name' => $service_name,
'device_id' => $device_id,
'dm_web_url' => $dm_web_url,
'registration_path' => $registration_path,
'divoom_install_dir' => $divoom_install_dir,
'agent_install_dir' => $agent_install_dir,
'agent_binary_path' => $agent_binary_path,
'state_dir' => $state_dir,
'log_dir' => $log_dir,
}),
notify => Exec['fc-divoom-dm-agent-systemd-reload'],
require => File[$registration_path],
}
exec { 'fc-divoom-dm-agent-systemd-reload':
command => '/usr/bin/systemctl daemon-reload',
refreshonly => true,
path => ['/usr/bin', '/bin'],
}
service { $service_name:
ensure => $service_ensure,
enable => $service_enabled,
require => [
File["/etc/systemd/system/${service_name}.service"],
File[$registration_path],
Exec['fc-divoom-dm-agent-systemd-reload'],
],
}
}
}

View File

@@ -0,0 +1,34 @@
{
"deviceId": "<%= $device_id %>",
"displayName": "<%= $display_name %>",
"hostFqdn": "<%= $host_fqdn %>",
"kind": "DivoomMiniToo",
"managedBy": "FlowerCore.DeviceManagement",
"executionMode": "Pi",
"transport": {
"kind": "BluetoothSerial",
"candidateChannels": <%= $bt_channels_json %>,
"defaultChannel": "<%= $default_bt_channel %>",
"deviceInfoIsRenderProof": false,
"visibleRenderProofRequired": <%= $visible_render_proof_required %>
},
"paths": {
"divoomInstallDir": "<%= $divoom_install_dir %>",
"btLink": "<%= $divoom_install_dir %>/bt-link.sh",
"btReset": "<%= $divoom_install_dir %>/bt-reset.sh",
"audioLink": "<%= $divoom_install_dir %>/audio-link.sh"
},
"capabilities": {
"supportsBluetoothSerial": true,
"supportsBtChannelRedetect": true,
"supportsBtHardReset": true,
"supportsBtAudioProfileSwitch": true,
"a2dpDefaultState": "<%= $a2dp_default_state %>",
"fmRadioEnabled": <%= $fm_radio_enabled %>
},
"safety": {
"preserveExistingService": "flowercore-divoom.service",
"preserveDataDirectory": "<%= $divoom_install_dir %>/data",
"doNotEnableFmRadio": true
}
}

View File

@@ -0,0 +1,36 @@
[Unit]
Description=FlowerCore Divoom DM Agent Bluetooth executor
Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/divoom-tv-hdmi-multitarget-render-substrate.md
Wants=network-online.target
After=network-online.target bluetooth.service
Requires=bluetooth.service
ConditionPathExists=<%= $agent_binary_path %>
ConditionPathExists=<%= $registration_path %>
ConditionPathExists=<%= $divoom_install_dir %>/bt-link.sh
ConditionPathExists=<%= $divoom_install_dir %>/bt-reset.sh
ConditionPathExists=<%= $divoom_install_dir %>/audio-link.sh
[Service]
Type=simple
User=stoltz
Group=stoltz
WorkingDirectory=<%= $agent_install_dir %>
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
Environment=FLOWERCORE_DM_DEVICE_REGISTRATION=<%= $registration_path %>
Environment=Divoom__Bluetooth__DeviceInfoIsRenderProof=false
Environment=Divoom__Bluetooth__VisibleRenderProofRequired=true
Environment=Divoom__Bluetooth__A2dpDefaultState=off
ExecStart=<%= $agent_binary_path %> --mode=Pi --device-id=<%= $device_id %> --dm-web-url=<%= $dm_web_url %> --registration=<%= $registration_path %>
Restart=on-failure
RestartSec=10s
StartLimitBurst=3
StartLimitIntervalSec=300s
SupplementaryGroups=bluetooth audio dialout
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=<%= $state_dir %> <%= $log_dir %>
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,44 @@
# FlowerCore Divoom TV Pi HDMI
Source-controlled deploy shape for the native `FlowerCore.Divoom.Tv`
Avalonia HDMI renderer on a Raspberry Pi connected to a TV.
This is a Puppet/systemd appliance bundle, not a Kubernetes application. It
mirrors the existing `fc-signage-pi-player` pattern: bluejay-infra carries the
systemd units, scripts, Hiera shape, and Puppet profile source that
`FlowerCore.Puppet` vendors and installs.
## Scope
- Launch the future `FlowerCore.Divoom.Tv` linux-arm64 self-contained payload
from `/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv`.
- Prefer `cage` as the Wayland fullscreen compositor, with direct app launch as
a fallback for development images.
- Restart the app after HDMI hotplug with a 2 second DRM settle delay.
- Keep all runtime state local: `/var/lib/fc-divoom-tv` and
`/var/log/fc-divoom-tv`.
- Avoid CDN/runtime fetches; the app renders the in-house Divoom scene catalog
locally.
## Artifact Map
| Path | Use |
| --- | --- |
| `systemd/flowercore-divoom-tv.service` | Fullscreen Avalonia HDMI app service. |
| `systemd/flowercore-divoom-tv-hdmi.service` | HDMI hotplug responder service. |
| `systemd/99-flowercore-divoom-tv-hdmi.rules` | DRM udev hotplug rule. |
| `scripts/flowercore-divoom-tv-prelaunch.sh` | Preflight checks and local directory creation. |
| `scripts/flowercore-divoom-tv-launch.sh` | Cage-first fullscreen launcher. |
| `scripts/flowercore-divoom-tv-hdmi-respond.sh` | Hotplug settle and restart script. |
| `puppet/profile/pi/service/divoom_tv.pp` | Puppet profile shape to vendor into `FlowerCore.Puppet`. |
| `hiera/example-divoom-tv-pi.iamworkin.lan.yaml` | Example node Hiera for a Divoom TV Pi. |
## Rollout Notes
1. Build `FlowerCore.Divoom.Tv` with `dotnet.exe publish -c Release -r linux-arm64 --self-contained`.
2. Stage the payload to `/opt/flowercore/divoom-tv/` through the standard noc1
jump path and avoid `/tmp` for unprivileged Pi scratch.
3. Vendor the profile and static files into `FlowerCore.Puppet`.
4. Run Puppet noop, then apply on the target Pi.
5. Prove deployment with `systemctl is-active flowercore-divoom-tv.service`,
journal lines showing frames presented, and a visible HDMI display check.

View File

@@ -0,0 +1,19 @@
---
# Example node data for a dedicated Pi -> HDMI -> TV Divoom renderer.
# Copy into FlowerCore.Puppet data/nodes/<hostname>.iamworkin.lan.yaml only
# after the Pi has a static DHCP/DNS entry and the linux-arm64 payload exists.
facts:
role: pi_prototype
profile::motd::role: 'Divoom TV HDMI Renderer'
profile::pi::service::divoom_tv::ensure: 'present'
profile::pi::service::divoom_tv::service_enabled: true
profile::pi::service::divoom_tv::service_ensure: 'running'
profile::pi::service::divoom_tv::install_dir: '/opt/flowercore/divoom-tv'
profile::pi::service::divoom_tv::state_dir: '/var/lib/fc-divoom-tv'
profile::pi::service::divoom_tv::log_dir: '/var/log/fc-divoom-tv'
profile::pi::service::divoom_tv::presentation_mode: 'PillarboxSquare'
profile::pi::service::divoom_tv::startup_scene: 'bluejay-clock'
profile::pi::service::divoom_tv::reduced_motion: false

View File

@@ -0,0 +1,149 @@
# Drop into FlowerCore.Puppet site-modules/profile/manifests/pi/service/divoom_tv.pp.
# Static files come from profile/pi/fc_divoom_tv/ after this bluejay-infra
# bundle is vendored into the Puppet control repo.
class profile::pi::service::divoom_tv (
Enum['present', 'absent'] $ensure = 'present',
Boolean $service_enabled = false,
Enum['running', 'stopped'] $service_ensure = 'stopped',
String $service_name = 'flowercore-divoom-tv',
String $user = 'fc-divoom-tv',
String $group = 'fc-divoom-tv',
String $install_dir = '/opt/flowercore/divoom-tv',
String $state_dir = '/var/lib/fc-divoom-tv',
String $log_dir = '/var/log/fc-divoom-tv',
String $presentation_mode = 'PillarboxSquare',
String $startup_scene = 'bluejay-clock',
Boolean $reduced_motion = false,
) {
include profile::workstation::safe_account_exclusion
$safe_account = $profile::workstation::safe_account_exclusion::safe_account
if $safe_account {
notify { 'fc-divoom-tv safe-account exclusion':
message => 'SAFE-ACCOUNT-EXCLUSION: Divoom TV Pi profile refused to apply on operator workstation',
}
} elsif $ensure == 'absent' {
service { $service_name:
ensure => stopped,
enable => false,
}
file { [
"/etc/systemd/system/${service_name}.service",
"/etc/systemd/system/${service_name}-hdmi.service",
'/etc/udev/rules.d/99-flowercore-divoom-tv-hdmi.rules',
'/usr/local/bin/flowercore-divoom-tv-prelaunch.sh',
'/usr/local/bin/flowercore-divoom-tv-launch.sh',
'/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh',
'/etc/flowercore/divoom-tv.env',
]:
ensure => absent,
}
} else {
case $facts['os']['family'] {
'Debian': {}
default: { fail("profile::pi::service::divoom_tv only supports Debian-family OS, got ${facts['os']['family']}") }
}
package { ['cage', 'libgbm1', 'libdrm2', 'libxkbcommon0', 'fonts-dejavu-core']:
ensure => installed,
}
group { $group:
ensure => present,
system => true,
}
user { $user:
ensure => present,
system => true,
gid => $group,
home => $state_dir,
managehome => false,
shell => '/usr/sbin/nologin',
require => Group[$group],
}
file { [$install_dir, $state_dir, $log_dir, '/etc/flowercore']:
ensure => directory,
owner => $user,
group => $group,
mode => '0755',
}
file { '/etc/flowercore/divoom-tv.env':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => "FC_DIVOOM_TV_PRESENTATION_MODE=${presentation_mode}\nFC_DIVOOM_TV_START_SCENE=${startup_scene}\nFC_DIVOOM_TV_REDUCED_MOTION=${reduced_motion}\n",
require => File['/etc/flowercore'],
}
$script_map = {
'/usr/local/bin/flowercore-divoom-tv-prelaunch.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-prelaunch.sh',
'/usr/local/bin/flowercore-divoom-tv-launch.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-launch.sh',
'/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-hdmi-respond.sh',
}
$script_map.each |$dest, $src| {
file { $dest:
ensure => file,
owner => 'root',
group => 'root',
mode => '0755',
source => "puppet:///modules/${src}",
}
}
$unit_map = {
"/etc/systemd/system/${service_name}.service" => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv.service',
"/etc/systemd/system/${service_name}-hdmi.service" => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-hdmi.service',
}
$unit_map.each |$dest, $src| {
file { $dest:
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
source => "puppet:///modules/${src}",
notify => Exec['fc-divoom-tv-systemd-reload'],
}
}
file { '/etc/udev/rules.d/99-flowercore-divoom-tv-hdmi.rules':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
source => 'puppet:///modules/profile/pi/fc_divoom_tv/99-flowercore-divoom-tv-hdmi.rules',
notify => Exec['fc-divoom-tv-udev-reload'],
}
exec { 'fc-divoom-tv-systemd-reload':
command => '/usr/bin/systemctl daemon-reload',
refreshonly => true,
path => ['/usr/bin', '/bin'],
}
exec { 'fc-divoom-tv-udev-reload':
command => '/usr/bin/udevadm control --reload-rules',
refreshonly => true,
path => ['/usr/bin', '/bin'],
}
service { $service_name:
ensure => $service_ensure,
enable => $service_enabled,
require => [
File["/etc/systemd/system/${service_name}.service"],
File['/etc/flowercore/divoom-tv.env'],
File['/usr/local/bin/flowercore-divoom-tv-prelaunch.sh'],
File['/usr/local/bin/flowercore-divoom-tv-launch.sh'],
Exec['fc-divoom-tv-systemd-reload'],
],
}
}
}

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
sleep 2
systemctl restart flowercore-divoom-tv.service

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
APP_BIN="${FC_DIVOOM_TV_BIN:-/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv}"
STATE_DIR="${FC_DIVOOM_TV_STATE_DIR:-/var/lib/fc-divoom-tv}"
LOG_DIR="${FC_DIVOOM_TV_LOG_DIR:-/var/log/fc-divoom-tv}"
PRESENTATION_MODE="${FC_DIVOOM_TV_PRESENTATION_MODE:-PillarboxSquare}"
START_SCENE="${FC_DIVOOM_TV_START_SCENE:-bluejay-clock}"
REDUCED_MOTION="${FC_DIVOOM_TV_REDUCED_MOTION:-false}"
COMMON_ARGS=(
"--target=hdmi"
"--presentation-mode=${PRESENTATION_MODE}"
"--startup-scene=${START_SCENE}"
"--reduced-motion=${REDUCED_MOTION}"
"--state-dir=${STATE_DIR}"
"--log-dir=${LOG_DIR}"
)
if command -v cage >/dev/null 2>&1; then
exec cage -- "${APP_BIN}" "${COMMON_ARGS[@]}" "$@"
fi
echo "[$(date -Is)] cage not found; launching FlowerCore.Divoom.Tv directly" >&2
exec "${APP_BIN}" "${COMMON_ARGS[@]}" "$@"

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
APP_BIN="${FC_DIVOOM_TV_BIN:-/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv}"
STATE_DIR="${FC_DIVOOM_TV_STATE_DIR:-/var/lib/fc-divoom-tv}"
LOG_DIR="${FC_DIVOOM_TV_LOG_DIR:-/var/log/fc-divoom-tv}"
mkdir -p "${STATE_DIR}" "${LOG_DIR}"
if [[ ! -x "${APP_BIN}" ]]; then
echo "[$(date -Is)] missing executable ${APP_BIN}" >&2
exit 1
fi
if [[ -d /sys/class/drm ]] && ! find /sys/class/drm -maxdepth 1 -name 'card*-HDMI-A-*' -print -quit | grep -q .; then
echo "[$(date -Is)] no HDMI connector visible yet; continuing so the app can wait for display" >&2
fi
if command -v cage >/dev/null 2>&1; then
echo "[$(date -Is)] cage available for fullscreen Wayland launch"
else
echo "[$(date -Is)] cage not installed; direct launch fallback will be used" >&2
fi

View File

@@ -0,0 +1,2 @@
# Settle DRM for 2s before restarting the fullscreen Avalonia renderer.
SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl start flowercore-divoom-tv-hdmi.service"

View File

@@ -0,0 +1,7 @@
[Unit]
Description=FlowerCore Divoom TV HDMI hotplug responder
DefaultDependencies=no
[Service]
Type=oneshot
ExecStart=/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh

View File

@@ -0,0 +1,40 @@
[Unit]
Description=FlowerCore Divoom TV HDMI Renderer (Avalonia fullscreen)
Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/divoom-tv-hdmi-multitarget-render-substrate.md
Wants=network-online.target
After=network-online.target systemd-user-sessions.service
ConditionPathExists=/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv
[Service]
Type=simple
User=fc-divoom-tv
Group=fc-divoom-tv
WorkingDirectory=/opt/flowercore/divoom-tv
EnvironmentFile=-/etc/flowercore/divoom-tv.env
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
Environment=XDG_RUNTIME_DIR=/run/fc-divoom-tv
RuntimeDirectory=fc-divoom-tv
RuntimeDirectoryMode=0700
ExecStartPre=/usr/local/bin/flowercore-divoom-tv-prelaunch.sh
ExecStart=/usr/local/bin/flowercore-divoom-tv-launch.sh
Restart=always
RestartSec=10s
StartLimitBurst=5
StartLimitIntervalSec=300s
MemoryMax=2G
MemoryHigh=1500M
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/fc-divoom-tv /var/log/fc-divoom-tv /run/fc-divoom-tv
TTYPath=/dev/tty1
StandardInput=tty
StandardOutput=journal
StandardError=journal
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes
[Install]
WantedBy=graphical.target

View File

@@ -30,3 +30,26 @@ spec:
port: 80
tls:
secretName: dms-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose dms-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: dms-web-public
# namespace: fc-dms
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`dms.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: dms-web-public-profile-header # injects entitlement profile
# services:
# - name: dms-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

563
apps/fc-dns/fc-dns.yaml Normal file
View File

@@ -0,0 +1,563 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-dns
labels:
app.kubernetes.io/part-of: flowercore
---
# 1Password-backed Secret for the pfSense admin password.
# The operator watches this CRD, resolves the vault item, and produces a
# K8s Secret of the same name with each 1P field as a key. The `password`
# field of the "pfSense Admin" item becomes Secret key `password`.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: pfsense-admin
namespace: fc-dns
spec:
itemPath: "vaults/IAmWorkin/items/pfSense Admin"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: dns-oidc-client
namespace: fc-dns
spec:
itemPath: "vaults/IAmWorkin/items/dns-oidc-client"
---
# Service X-Api-Key for the cert-manager ACME webhook -> dns-web call path
# (Phase 0 auth-flip). The 1Password operator resolves this item into a K8s
# Secret of the same name; the `api_key` field becomes Secret key `api_key`.
# dns-web reads it as FlowerCore__Auth__ApiKey (FcApiKey scheme, Operator
# principal); dns-acme-webhook sends it as the X-Api-Key header. Dormant while
# FlowerCore__Auth__Enabled=false (all policies allow-all).
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: dns-api-keys
namespace: fc-dns
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore DNS API Keys"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: dns-web-data
namespace: fc-dns
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dns-web-config
namespace: fc-dns
data:
appsettings.Production.json: |
{
"FlowerCore": {
"Auth": {
"Enabled": false,
"Oidc": {
"Enabled": true,
"Audience": "dns",
"RequireHttpsMetadata": true
}
},
"Database": {
"Provider": "Sqlite",
"ConnectionStrings": {
"Sqlite": "Data Source=/data/dns.db"
}
},
"Tenant": {
"DefaultTenantId": "default",
"JwtClaimsEnabled": false,
"DefaultTenantHosts": [
"dns.iamworkin.lan"
]
},
"Audit": {
"HashChain": {
"BridgeSensitivity": {
"Distribution": "Warn"
}
}
}
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dns-web
namespace: fc-dns
labels:
app.kubernetes.io/name: dns-web
app.kubernetes.io/managed-by: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: dns-web
template:
metadata:
labels:
app.kubernetes.io/name: dns-web
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "5320"
prometheus.io/path: "/metrics/prometheus"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec:
serviceAccountName: dns-web
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
fsGroup: 1654
containers:
- name: dns-web
image: localhost/fc-dns-web:v20260615-phase0-hybrid-f77fb94
imagePullPolicy: Never
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
ports:
- containerPort: 5320
env:
# pfSense admin password resolved by the 1Password operator.
# `FallbackPassword` is the Slice A seam exposed by
# OptionsFallbackPasswordResolver; Slice B will replace it with
# a pull-at-runtime 1P Connect resolver once Shared.Vault ships.
- name: FlowerCore__Dns__Providers__PfSenseUnbound__FallbackPassword
valueFrom:
secretKeyRef:
name: pfsense-admin
key: password
- name: FlowerCore__Auth__Oidc__Authority
valueFrom:
secretKeyRef:
name: dns-oidc-client
key: issuer_url
optional: true
- name: FlowerCore__Auth__Oidc__ClientId
valueFrom:
secretKeyRef:
name: dns-oidc-client
key: client_id
optional: true
- name: FlowerCore__Auth__Oidc__ClientSecret
valueFrom:
secretKeyRef:
name: dns-oidc-client
key: client_secret
optional: true
# Service X-Api-Key accepted by the FcApiKey scheme. The standard
# key maps to an Operator principal (satisfies OperatorPolicy on the
# ACME present/cleanup endpoints). optional:true keeps the pod
# starting if the 1P operator has not yet produced the secret.
- name: FlowerCore__Auth__ApiKey
valueFrom:
secretKeyRef:
name: dns-api-keys
key: api_key
optional: true
- name: FlowerCore__Auth__Enabled
value: "false"
- name: FlowerCore__Auth__Oidc__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Audience
value: "dns"
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
- name: config
mountPath: /app/appsettings.Production.json
subPath: appsettings.Production.json
readOnly: true
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
readinessProbe:
httpGet:
path: /healthz
port: 5320
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 5320
initialDelaySeconds: 20
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: dns-web-data
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
- name: config
configMap:
name: dns-web-config
---
apiVersion: v1
kind: Service
metadata:
name: dns-web
namespace: fc-dns
spec:
selector:
app.kubernetes.io/name: dns-web
ports:
- port: 5320
targetPort: 5320
type: ClusterIP
---
# Defense-in-depth ingress isolation for dns-web (Phase 0). NetworkPolicy is
# L3/L4 and cannot path-scope, so it CANNOT restrict only present/cleanup — the
# real control on those endpoints is the X-Api-Key + OperatorPolicy. This policy
# simply confines who may reach dns-web:5320 to known network zones without
# breaking any live path:
# * Traefik pods -> UI/API on dns.iamworkin.lan
# * same fc-dns namespace -> dns-acme-webhook -> present/cleanup
# * cluster pod CIDR (10.42/16) -> in-cluster Prometheus scrape, etc.
# * node + LAN CIDRs -> kubelet probes, noc1 host-net Prometheus
# Egress is intentionally left unrestricted: dns-web must reach pfSense
# (diag_command.php / HTTPS), the K8s API, Authentik OIDC discovery, step-ca,
# and DNS — over-tight egress would break the provider + auth paths.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: dns-web-ingress-isolation
namespace: fc-dns
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: dns-web
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-dns
- ipBlock:
cidr: 10.42.0.0/16
- ipBlock:
cidr: 10.0.56.0/24
- ipBlock:
cidr: 10.0.57.0/24
- ipBlock:
cidr: 10.0.58.0/24
- ipBlock:
cidr: 10.0.68.0/27
ports:
- port: 5320
protocol: TCP
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: dns-web
namespace: fc-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: dns-web
rules:
- apiGroups: [""]
resources: ["namespaces", "pods", "services", "secrets", "configmaps"]
verbs: ["get", "list", "watch"]
- apiGroups: ["cert-manager.io"]
resources: ["certificates"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dns-web
subjects:
- kind: ServiceAccount
name: dns-web
namespace: fc-dns
roleRef:
kind: ClusterRole
name: dns-web
apiGroup: rbac.authorization.k8s.io
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dns-web-cert
namespace: fc-dns
spec:
secretName: dns-web-tls
issuerRef:
name: step-ca-dns01
kind: ClusterIssuer
dnsNames:
- dns.iamworkin.lan
duration: 720h
renewBefore: 240h
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: dns-web
namespace: fc-dns
spec:
entryPoints: [websecure]
routes:
- match: Host(`dns.iamworkin.lan`)
kind: Rule
services:
- name: dns-web
port: 5320
tls:
secretName: dns-web-tls
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: dns-acme-webhook
namespace: fc-dns
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dns-acme-webhook
namespace: fc-dns
labels:
app.kubernetes.io/name: dns-acme-webhook
app.kubernetes.io/managed-by: flowercore
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: dns-acme-webhook
template:
metadata:
labels:
app.kubernetes.io/name: dns-acme-webhook
spec:
serviceAccountName: dns-acme-webhook
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
fsGroup: 1654
containers:
- name: dns-acme-webhook
image: localhost/fc-dns-acme-webhook:v20260615-phase0-hybrid-f77fb94
imagePullPolicy: Never
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
ports:
- containerPort: 9443
name: https
env:
- name: ASPNETCORE_URLS
value: https://+:9443
- name: Kestrel__Certificates__Default__Path
value: /tls/tls.crt
- name: Kestrel__Certificates__Default__KeyPath
value: /tls/tls.key
- name: FlowerCore__Dns__AcmeWebhook__ServiceBaseUrl
value: http://dns-web:5320
# X-Api-Key sent to dns-web on present/cleanup so the webhook
# authenticates as an Operator once dns-web auth is enabled.
# optional:true keeps the webhook starting before the 1P secret
# exists; the header is simply omitted when the value is empty.
- name: FlowerCore__Dns__AcmeWebhook__ApiKey
valueFrom:
secretKeyRef:
name: dns-api-keys
key: api_key
optional: true
- name: FlowerCore__Dns__AcmeWebhook__GroupName
value: acme.flowercore.io
- name: FlowerCore__Dns__AcmeWebhook__SolverName
value: flowercore-dns
- name: FlowerCore__Dns__AcmeWebhook__Version
value: v1alpha1
volumeMounts:
- name: tls
mountPath: /tls
readOnly: true
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
readinessProbe:
httpGet:
scheme: HTTPS
path: /readyz
port: https
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
scheme: HTTPS
path: /healthz
port: https
initialDelaySeconds: 10
periodSeconds: 20
timeoutSeconds: 5
volumes:
- name: tls
secret:
secretName: dns-acme-webhook-tls
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: dns-acme-webhook
namespace: fc-dns
spec:
selector:
app.kubernetes.io/name: dns-acme-webhook
ports:
- port: 443
targetPort: https
name: https
type: ClusterIP
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: dns-acme-webhook-selfsigned
namespace: fc-dns
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dns-acme-webhook-ca
namespace: fc-dns
spec:
secretName: dns-acme-webhook-ca
duration: 43800h
issuerRef:
name: dns-acme-webhook-selfsigned
commonName: ca.dns-acme-webhook.fc-dns
isCA: true
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: dns-acme-webhook-ca-issuer
namespace: fc-dns
spec:
ca:
secretName: dns-acme-webhook-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dns-acme-webhook-serving-cert
namespace: fc-dns
spec:
secretName: dns-acme-webhook-tls
duration: 8760h
issuerRef:
name: dns-acme-webhook-ca-issuer
dnsNames:
- dns-acme-webhook
- dns-acme-webhook.fc-dns
- dns-acme-webhook.fc-dns.svc
---
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
name: v1alpha1.acme.flowercore.io
annotations:
cert-manager.io/inject-ca-from: fc-dns/dns-acme-webhook-serving-cert
spec:
group: acme.flowercore.io
groupPriorityMinimum: 1000
service:
name: dns-acme-webhook
namespace: fc-dns
version: v1alpha1
versionPriority: 15
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: dns-acme-webhook-solver
rules:
- apiGroups: ["acme.flowercore.io"]
resources: ["flowercore-dns"]
verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dns-acme-webhook-solver
subjects:
- kind: ServiceAccount
name: cert-manager
namespace: cert-manager
roleRef:
kind: ClusterRole
name: dns-acme-webhook-solver
apiGroup: rbac.authorization.k8s.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: step-ca-dns01
spec:
acme:
caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ4RENDQVdxZ0F3SUJBZ0lSQVBZMzU3RzZvdzZ6TUFMNSs0YlMya2t3Q2dZSUtvWkl6ajBFQXdJd1FERWEKTUJnR0ExVUVDaE1SU1VGdFYyOXlhMmx1SUVGRFRVVWdRMEV4SWpBZ0JnTlZCQU1UR1VsQmJWZHZjbXRwYmlCQgpRMDFGSUVOQklGSnZiM1FnUTBFd0hoY05Nall3TXpBNE1UZ3dOekV4V2hjTk16WXdNekExTVRnd056RXhXakJBCk1Sb3dHQVlEVlFRS0V4RkpRVzFYYjNKcmFXNGdRVU5OUlNCRFFURWlNQ0FHQTFVRUF4TVpTVUZ0VjI5eWEybHUKSUVGRFRVVWdRMEVnVW05dmRDQkRRVEJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCSjJuMDRYMQpKWm81WmRxL2kxSWR2OCtmcXdaeUF6Qmg3d2hicWowU1dzSkw4VVdSYWJDTXFZQ3M3K2RYTzB4UlN6cWt3RkRMCngrdm9vT2FpOFJnUk5oYWpSVEJETUE0R0ExVWREd0VCL3dRRUF3SUJCakFTQmdOVkhSTUJBZjhFQ0RBR0FRSC8KQWdFQk1CMEdBMVVkRGdRV0JCUm51UFBRUjZpTS9INnZPbHVpVTNTeWdheXo4akFLQmdncWhrak9QUVFEQWdOSQpBREJGQWlFQXJRSzlkWVBHbUFac2RZbmp6aXVGVlZFNU5LWlVjY2VZdkdmR0MrdExYVXNDSUF1ZEYyekpyQ1JxCjNtSzUwWlpFVC9md1RrSndpRUY0ODI0bWpQOHAxQ0tNCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
privateKeySecretRef:
name: step-ca-dns01-account-key
server: https://10.0.56.10:9443/acme/acme/directory
solvers:
- dns01:
webhook:
groupName: acme.flowercore.io
solverName: flowercore-dns

View File

@@ -0,0 +1,6 @@
# ArgoCD's bluejay-infra ApplicationSet discovers apps/* directories on main.
# The kustomization is included for local previews and single-app validation.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- fc-dns.yaml

View File

@@ -9,7 +9,7 @@ metadata:
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Landing page HTML (public-safe - no internal LAN references)
# Landing page HTML (public-safe - no internal LAN references, no CDN)
apiVersion: v1
kind: ConfigMap
metadata:
@@ -20,144 +20,357 @@ data:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FlowerCore</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a1628 0%, #1a2744 50%, #0d1f3c 100%);
color: #e0e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.hero {
text-align: center;
padding: 3rem;
max-width: 800px;
}
.logo {
font-size: 5rem;
margin-bottom: 1.5rem;
filter: drop-shadow(0 0 20px rgba(74, 158, 255, 0.3));
}
h1 {
font-size: 3rem;
background: linear-gradient(135deg, #4a9eff, #7ab3ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1.3rem;
color: #7ab3ff;
font-weight: 300;
margin-bottom: 1rem;
}
.description {
font-size: 1rem;
color: #8aa8c4;
line-height: 1.6;
margin-bottom: 3rem;
max-width: 600px;
}
.services {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
width: 100%;
max-width: 700px;
padding: 0 1rem;
}
.service {
background: rgba(74, 158, 255, 0.08);
border: 1px solid rgba(74, 158, 255, 0.2);
border-radius: 8px;
padding: 1.2rem;
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.service:hover {
background: rgba(74, 158, 255, 0.15);
border-color: rgba(74, 158, 255, 0.5);
transform: translateY(-2px);
}
.service h3 { color: #4a9eff; font-size: 0.95rem; margin-bottom: 0.3rem; }
.service p { color: #8aa8c4; font-size: 0.8rem; }
.status-bar {
display: flex;
gap: 2rem;
margin-top: 2rem;
padding: 1rem 2rem;
background: rgba(74, 158, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(74, 158, 255, 0.1);
}
.status-item { text-align: center; }
.status-item .value { color: #4a9eff; font-size: 1.5rem; font-weight: 700; }
.status-item .label { color: #6a8ca4; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; }
.footer {
margin-top: 3rem;
color: #4a6580;
font-size: 0.8rem;
}
.footer a { color: #4a6580; text-decoration: none; }
.footer a:hover { color: #7ab3ff; }
</style>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FlowerCore</title>
<!--
FlowerCore public landing page.
Air-gap safe: zero CDN, zero external fonts, zero external scripts.
All assets are inline SVG / CSS / JS only.
Update Center link: https://update.flowercore.io/
Animation: bloom mark (8-petal flower) rotates slowly with a floating
seed-core pulse. Respects prefers-reduced-motion (animation disabled).
-->
<style>
/* ── Blue Jay palette (matches decisions-waiting.html tokens) ── */
:root {
--bg: #0A1628;
--bg2: #0E1E36;
--surface: #111D33;
--border: #1E3A5F;
--text: #E8EDF5;
--text-muted: #8899B3;
--gold: #FFB300;
--gold-light: #FFCA40;
--gold-dim: #CC8F00;
--ice: #82BBFF;
--steel: #2E5FA3;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
background: radial-gradient(ellipse at 60% 30%, #13233E 0%, #0C1A30 55%, #081222 100%);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
overflow-x: hidden;
}
/* ── Skip link (keyboard accessibility) ── */
.skip-link {
position: absolute;
left: -999px;
top: 0;
background: var(--gold);
color: #0A1628;
padding: 8px 16px;
border-radius: 0 0 8px 0;
font-weight: 700;
z-index: 100;
text-decoration: none;
}
.skip-link:focus { left: 0; }
/* ── Focus ring ── */
a:focus-visible, button:focus-visible {
outline: 3px solid var(--ice);
outline-offset: 3px;
border-radius: 6px;
}
/* ── Background star field (CSS only, no JS needed) ── */
.starfield {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.star {
position: absolute;
border-radius: 50%;
background: #fff;
opacity: 0;
animation: twinkle var(--d, 4s) var(--delay, 0s) ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0; transform: scale(0.5); }
50% { opacity: var(--op, 0.5); transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.star { animation: none; opacity: 0.2; }
}
/* ── Main content ── */
main {
position: relative;
z-index: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
max-width: 520px;
width: 100%;
}
/* ── Bloom mark container ── */
.bloom-wrap {
width: 180px;
height: 180px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
}
/* Outer glow ring */
.bloom-wrap::before {
content: '';
position: absolute;
inset: -24px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,179,0,0.12) 0%, transparent 70%);
animation: pulse-glow 3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bloom-wrap::before { animation: none; }
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.12); opacity: 0.7; }
}
/* The bloom SVG itself */
.bloom-svg {
width: 160px;
height: 160px;
animation: bloom-spin 24s linear infinite;
transform-origin: center;
filter: drop-shadow(0 0 18px rgba(255,179,0,0.35));
}
@media (prefers-reduced-motion: reduce) {
.bloom-svg { animation: none; }
}
@keyframes bloom-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Seed core counter-rotates so it stays upright */
.bloom-core {
animation: bloom-counter 24s linear infinite;
transform-origin: 80px 80px; /* center of 160px viewBox */
}
@media (prefers-reduced-motion: reduce) {
.bloom-core { animation: none; }
}
@keyframes bloom-counter {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
/* Core pulse */
.bloom-core-circle {
animation: core-pulse 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bloom-core-circle { animation: none; }
}
@keyframes core-pulse {
0%, 100% { r: 13; }
50% { r: 17; }
}
/* ── Wordmark ── */
.wordmark {
letter-spacing: 0.22em;
font-size: 1.9rem;
font-weight: 800;
color: var(--text);
text-transform: uppercase;
margin-bottom: 0.35rem;
line-height: 1;
}
.tagline {
color: var(--text-muted);
font-size: 0.9rem;
letter-spacing: 0.12em;
margin-bottom: 2.5rem;
}
/* ── Update Center link (primary action) ── */
.update-link {
display: inline-flex;
align-items: center;
gap: 0.55rem;
background: var(--gold);
color: #0A1628;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.04em;
padding: 0.75rem 1.75rem;
border-radius: 8px;
text-decoration: none;
border: 2px solid var(--gold);
transition: background 0.18s, border-color 0.18s, transform 0.15s;
margin-bottom: 3rem;
}
.update-link:hover {
background: var(--gold-light);
border-color: var(--gold-light);
transform: translateY(-2px);
}
.update-link:active {
background: var(--gold-dim);
border-color: var(--gold-dim);
transform: none;
}
/* Arrow icon inside the button */
.update-link svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* ── "More soon" note ── */
.more-soon {
color: var(--text-muted);
font-size: 0.78rem;
letter-spacing: 0.06em;
opacity: 0.7;
}
/* ── Footer ── */
footer {
position: relative;
z-index: 1;
margin-top: 3rem;
color: #2E5FA3;
font-size: 0.72rem;
letter-spacing: 0.05em;
opacity: 0.6;
}
/* ── Responsive ── */
@media (max-width: 400px) {
.bloom-wrap { width: 140px; height: 140px; }
.bloom-svg { width: 124px; height: 124px; }
.bloom-core { transform-origin: 62px 62px; }
.wordmark { font-size: 1.5rem; }
}
</style>
</head>
<body>
<div class="hero">
<div class="logo">&#x1F33B;</div>
<h1>FlowerCore</h1>
<p class="subtitle">Blue Jay Lab</p>
<p class="description">
Multi-tenant service management platform built on .NET 10,
Kubernetes, and GitOps. Digital signage, telephony IVR,
MySQL/PHP hosting, and infrastructure automation.
</p>
<a href="#main" class="skip-link">Skip to content</a>
<!-- CSS-only star field: 20 stars, varied positions/sizes/speeds -->
<div class="starfield" aria-hidden="true">
<div class="star" style="width:2px;height:2px;top:8%;left:12%;--d:5s;--delay:0.3s;--op:0.6"></div>
<div class="star" style="width:1px;height:1px;top:15%;left:75%;--d:7s;--delay:1.1s;--op:0.4"></div>
<div class="star" style="width:2px;height:2px;top:22%;left:45%;--d:4s;--delay:0.7s;--op:0.5"></div>
<div class="star" style="width:1px;height:1px;top:30%;left:88%;--d:6s;--delay:2.0s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:40%;left:5%;--d:8s;--delay:0.4s;--op:0.4"></div>
<div class="star" style="width:1px;height:1px;top:55%;left:30%;--d:5s;--delay:1.5s;--op:0.35"></div>
<div class="star" style="width:2px;height:2px;top:65%;left:62%;--d:7s;--delay:0.9s;--op:0.45"></div>
<div class="star" style="width:1px;height:1px;top:72%;left:20%;--d:4s;--delay:2.3s;--op:0.5"></div>
<div class="star" style="width:2px;height:2px;top:80%;left:82%;--d:6s;--delay:0.1s;--op:0.3"></div>
<div class="star" style="width:1px;height:1px;top:88%;left:50%;--d:9s;--delay:1.7s;--op:0.4"></div>
<div class="star" style="width:1px;height:1px;top:5%;left:55%;--d:6s;--delay:3.1s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:18%;left:92%;--d:5s;--delay:0.5s;--op:0.5"></div>
<div class="star" style="width:1px;height:1px;top:35%;left:68%;--d:7s;--delay:1.9s;--op:0.35"></div>
<div class="star" style="width:2px;height:2px;top:48%;left:18%;--d:4s;--delay:2.7s;--op:0.45"></div>
<div class="star" style="width:1px;height:1px;top:60%;left:95%;--d:8s;--delay:0.6s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:75%;left:40%;--d:6s;--delay:1.3s;--op:0.4"></div>
<div class="star" style="width:1px;height:1px;top:85%;left:70%;--d:5s;--delay:2.9s;--op:0.35"></div>
<div class="star" style="width:2px;height:2px;top:92%;left:8%;--d:7s;--delay:0.2s;--op:0.5"></div>
<div class="star" style="width:1px;height:1px;top:10%;left:33%;--d:4s;--delay:1.8s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:93%;left:88%;--d:9s;--delay:3.5s;--op:0.4"></div>
</div>
<main id="main">
<!-- Animated bloom mark -->
<div class="bloom-wrap" role="img" aria-label="FlowerCore bloom mark">
<!--
Bloom mark: 8 elliptical petals + outer ring + pulsing seed core.
Source of truth: scripts/reformat/branding/oem-logo-bluejay.svg
Petals rotate slowly (24s/rev). Core counter-rotates to stay upright.
Outer glow added via CSS. Respects prefers-reduced-motion.
-->
<svg class="bloom-svg" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" focusable="false">
<defs>
<radialGradient id="petal-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#FFCA40" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#FFB300" stop-opacity="0.7"/>
</radialGradient>
<radialGradient id="core-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#FFCA40"/>
<stop offset="100%" stop-color="#CC8F00"/>
</radialGradient>
<!-- Petal shape: thin tall ellipse offset upward from origin -->
<ellipse id="petal" cx="0" cy="-38" rx="6.5" ry="26"
fill="none" stroke="url(#petal-grad)" stroke-width="1.8" opacity="0.85"/>
</defs>
<!-- 8 petals, each 45° apart, centred at 80,80 -->
<g transform="translate(80 80)">
<use href="#petal" transform="rotate(0)"/>
<use href="#petal" transform="rotate(45)"/>
<use href="#petal" transform="rotate(90)"/>
<use href="#petal" transform="rotate(135)"/>
<use href="#petal" transform="rotate(180)"/>
<use href="#petal" transform="rotate(225)"/>
<use href="#petal" transform="rotate(270)"/>
<use href="#petal" transform="rotate(315)"/>
<!-- Outer ring -->
<circle r="44" fill="none" stroke="#FFB300" stroke-width="1.5" opacity="0.6"/>
<!-- Seed core: counter-rotates via CSS so it stays upright -->
<g class="bloom-core">
<circle class="bloom-core-circle" r="13" fill="url(#core-grad)"/>
<circle r="13" fill="none" stroke="#0A1628" stroke-width="1.5" opacity="0.45"/>
</g>
</g>
</svg>
</div>
<div class="services">
<a class="service" href="https://gitea.flowercore.io">
<h3>Source</h3>
<p>Gitea repositories</p>
</a>
<a class="service" href="https://webmail.flowercore.io">
<h3>Mail</h3>
<p>Webmail access</p>
</a>
<a class="service" href="https://element.flowercore.io">
<h3>Chat</h3>
<p>Matrix messaging</p>
</a>
<a class="service" href="https://github.com/FlowerCoreIO">
<h3>GitHub</h3>
<p>Open source</p>
</a>
</div>
<div class="status-bar">
<div class="status-item">
<div class="value">17</div>
<div class="label">Services</div>
</div>
<div class="status-item">
<div class="value">13</div>
<div class="label">VLANs</div>
</div>
<div class="status-item">
<div class="value">12k+</div>
<div class="label">Tests</div>
</div>
</div>
<p class="footer">
FlowerCore &middot; Bare-metal RKE2 &middot; ArgoCD managed
&middot; <a href="mailto:admin@flowercore.io">Contact</a>
</p>
<!-- Wordmark -->
<p class="wordmark">FlowerCore</p>
<p class="tagline">More to come.</p>
<!-- Primary CTA: Update Center -->
<a class="update-link" href="https://update.flowercore.io/" target="_blank"
rel="noopener noreferrer">
<!-- Inline download/arrow SVG — no external ref -->
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" focusable="false">
<path d="M8 2v8M4 7l4 4 4-4" stroke="#0A1628" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 13h12" stroke="#0A1628" stroke-width="2"
stroke-linecap="round"/>
</svg>
Update Center
</a>
<p class="more-soon">Software updates &amp; releases</p>
</main>
<footer>
&copy; FlowerCore
</footer>
</body>
</html>
---

View File

@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FlowerCore</title>
<!--
FlowerCore public landing page.
Air-gap safe: zero CDN, zero external fonts, zero external scripts.
All assets are inline SVG / CSS / JS only.
Update Center link: https://update.flowercore.io/
Animation: bloom mark (8-petal flower) rotates slowly with a floating
seed-core pulse. Respects prefers-reduced-motion (animation disabled).
-->
<style>
/* ── Blue Jay palette (matches decisions-waiting.html tokens) ── */
:root {
--bg: #0A1628;
--bg2: #0E1E36;
--surface: #111D33;
--border: #1E3A5F;
--text: #E8EDF5;
--text-muted: #8899B3;
--gold: #FFB300;
--gold-light: #FFCA40;
--gold-dim: #CC8F00;
--ice: #82BBFF;
--steel: #2E5FA3;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
background: radial-gradient(ellipse at 60% 30%, #13233E 0%, #0C1A30 55%, #081222 100%);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
overflow-x: hidden;
}
/* ── Skip link (keyboard accessibility) ── */
.skip-link {
position: absolute;
left: -999px;
top: 0;
background: var(--gold);
color: #0A1628;
padding: 8px 16px;
border-radius: 0 0 8px 0;
font-weight: 700;
z-index: 100;
text-decoration: none;
}
.skip-link:focus { left: 0; }
/* ── Focus ring ── */
a:focus-visible, button:focus-visible {
outline: 3px solid var(--ice);
outline-offset: 3px;
border-radius: 6px;
}
/* ── Background star field (CSS only, no JS needed) ── */
.starfield {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.star {
position: absolute;
border-radius: 50%;
background: #fff;
opacity: 0;
animation: twinkle var(--d, 4s) var(--delay, 0s) ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0; transform: scale(0.5); }
50% { opacity: var(--op, 0.5); transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.star { animation: none; opacity: 0.2; }
}
/* ── Main content ── */
main {
position: relative;
z-index: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
max-width: 520px;
width: 100%;
}
/* ── Bloom mark container ── */
.bloom-wrap {
width: 180px;
height: 180px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
}
/* Outer glow ring */
.bloom-wrap::before {
content: '';
position: absolute;
inset: -24px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,179,0,0.12) 0%, transparent 70%);
animation: pulse-glow 3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bloom-wrap::before { animation: none; }
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.12); opacity: 0.7; }
}
/* The bloom SVG itself */
.bloom-svg {
width: 160px;
height: 160px;
animation: bloom-spin 24s linear infinite;
transform-origin: center;
filter: drop-shadow(0 0 18px rgba(255,179,0,0.35));
}
@media (prefers-reduced-motion: reduce) {
.bloom-svg { animation: none; }
}
@keyframes bloom-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Seed core counter-rotates so it stays upright */
.bloom-core {
animation: bloom-counter 24s linear infinite;
transform-origin: 80px 80px; /* center of 160px viewBox */
}
@media (prefers-reduced-motion: reduce) {
.bloom-core { animation: none; }
}
@keyframes bloom-counter {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
/* Core pulse */
.bloom-core-circle {
animation: core-pulse 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bloom-core-circle { animation: none; }
}
@keyframes core-pulse {
0%, 100% { r: 13; }
50% { r: 17; }
}
/* ── Wordmark ── */
.wordmark {
letter-spacing: 0.22em;
font-size: 1.9rem;
font-weight: 800;
color: var(--text);
text-transform: uppercase;
margin-bottom: 0.35rem;
line-height: 1;
}
.tagline {
color: var(--text-muted);
font-size: 0.9rem;
letter-spacing: 0.12em;
margin-bottom: 2.5rem;
}
/* ── Update Center link (primary action) ── */
.update-link {
display: inline-flex;
align-items: center;
gap: 0.55rem;
background: var(--gold);
color: #0A1628;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.04em;
padding: 0.75rem 1.75rem;
border-radius: 8px;
text-decoration: none;
border: 2px solid var(--gold);
transition: background 0.18s, border-color 0.18s, transform 0.15s;
margin-bottom: 3rem;
}
.update-link:hover {
background: var(--gold-light);
border-color: var(--gold-light);
transform: translateY(-2px);
}
.update-link:active {
background: var(--gold-dim);
border-color: var(--gold-dim);
transform: none;
}
/* Arrow icon inside the button */
.update-link svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* ── "More soon" note ── */
.more-soon {
color: var(--text-muted);
font-size: 0.78rem;
letter-spacing: 0.06em;
opacity: 0.7;
}
/* ── Footer ── */
footer {
position: relative;
z-index: 1;
margin-top: 3rem;
color: #2E5FA3;
font-size: 0.72rem;
letter-spacing: 0.05em;
opacity: 0.6;
}
/* ── Responsive ── */
@media (max-width: 400px) {
.bloom-wrap { width: 140px; height: 140px; }
.bloom-svg { width: 124px; height: 124px; }
.bloom-core { transform-origin: 62px 62px; }
.wordmark { font-size: 1.5rem; }
}
</style>
</head>
<body>
<a href="#main" class="skip-link">Skip to content</a>
<!-- CSS-only star field: 20 stars, varied positions/sizes/speeds -->
<div class="starfield" aria-hidden="true">
<div class="star" style="width:2px;height:2px;top:8%;left:12%;--d:5s;--delay:0.3s;--op:0.6"></div>
<div class="star" style="width:1px;height:1px;top:15%;left:75%;--d:7s;--delay:1.1s;--op:0.4"></div>
<div class="star" style="width:2px;height:2px;top:22%;left:45%;--d:4s;--delay:0.7s;--op:0.5"></div>
<div class="star" style="width:1px;height:1px;top:30%;left:88%;--d:6s;--delay:2.0s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:40%;left:5%;--d:8s;--delay:0.4s;--op:0.4"></div>
<div class="star" style="width:1px;height:1px;top:55%;left:30%;--d:5s;--delay:1.5s;--op:0.35"></div>
<div class="star" style="width:2px;height:2px;top:65%;left:62%;--d:7s;--delay:0.9s;--op:0.45"></div>
<div class="star" style="width:1px;height:1px;top:72%;left:20%;--d:4s;--delay:2.3s;--op:0.5"></div>
<div class="star" style="width:2px;height:2px;top:80%;left:82%;--d:6s;--delay:0.1s;--op:0.3"></div>
<div class="star" style="width:1px;height:1px;top:88%;left:50%;--d:9s;--delay:1.7s;--op:0.4"></div>
<div class="star" style="width:1px;height:1px;top:5%;left:55%;--d:6s;--delay:3.1s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:18%;left:92%;--d:5s;--delay:0.5s;--op:0.5"></div>
<div class="star" style="width:1px;height:1px;top:35%;left:68%;--d:7s;--delay:1.9s;--op:0.35"></div>
<div class="star" style="width:2px;height:2px;top:48%;left:18%;--d:4s;--delay:2.7s;--op:0.45"></div>
<div class="star" style="width:1px;height:1px;top:60%;left:95%;--d:8s;--delay:0.6s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:75%;left:40%;--d:6s;--delay:1.3s;--op:0.4"></div>
<div class="star" style="width:1px;height:1px;top:85%;left:70%;--d:5s;--delay:2.9s;--op:0.35"></div>
<div class="star" style="width:2px;height:2px;top:92%;left:8%;--d:7s;--delay:0.2s;--op:0.5"></div>
<div class="star" style="width:1px;height:1px;top:10%;left:33%;--d:4s;--delay:1.8s;--op:0.3"></div>
<div class="star" style="width:2px;height:2px;top:93%;left:88%;--d:9s;--delay:3.5s;--op:0.4"></div>
</div>
<main id="main">
<!-- Animated bloom mark -->
<div class="bloom-wrap" role="img" aria-label="FlowerCore bloom mark">
<!--
Bloom mark: 8 elliptical petals + outer ring + pulsing seed core.
Source of truth: scripts/reformat/branding/oem-logo-bluejay.svg
Petals rotate slowly (24s/rev). Core counter-rotates to stay upright.
Outer glow added via CSS. Respects prefers-reduced-motion.
-->
<svg class="bloom-svg" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" focusable="false">
<defs>
<radialGradient id="petal-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#FFCA40" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#FFB300" stop-opacity="0.7"/>
</radialGradient>
<radialGradient id="core-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#FFCA40"/>
<stop offset="100%" stop-color="#CC8F00"/>
</radialGradient>
<!-- Petal shape: thin tall ellipse offset upward from origin -->
<ellipse id="petal" cx="0" cy="-38" rx="6.5" ry="26"
fill="none" stroke="url(#petal-grad)" stroke-width="1.8" opacity="0.85"/>
</defs>
<!-- 8 petals, each 45° apart, centred at 80,80 -->
<g transform="translate(80 80)">
<use href="#petal" transform="rotate(0)"/>
<use href="#petal" transform="rotate(45)"/>
<use href="#petal" transform="rotate(90)"/>
<use href="#petal" transform="rotate(135)"/>
<use href="#petal" transform="rotate(180)"/>
<use href="#petal" transform="rotate(225)"/>
<use href="#petal" transform="rotate(270)"/>
<use href="#petal" transform="rotate(315)"/>
<!-- Outer ring -->
<circle r="44" fill="none" stroke="#FFB300" stroke-width="1.5" opacity="0.6"/>
<!-- Seed core: counter-rotates via CSS so it stays upright -->
<g class="bloom-core">
<circle class="bloom-core-circle" r="13" fill="url(#core-grad)"/>
<circle r="13" fill="none" stroke="#0A1628" stroke-width="1.5" opacity="0.45"/>
</g>
</g>
</svg>
</div>
<!-- Wordmark -->
<p class="wordmark">FlowerCore</p>
<p class="tagline">More to come.</p>
<!-- Primary CTA: Update Center -->
<a class="update-link" href="https://update.flowercore.io/" target="_blank"
rel="noopener noreferrer">
<!-- Inline download/arrow SVG — no external ref -->
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" focusable="false">
<path d="M8 2v8M4 7l4 4 4-4" stroke="#0A1628" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 13h12" stroke="#0A1628" stroke-width="2"
stroke-linecap="round"/>
</svg>
Update Center
</a>
<p class="more-soon">Software updates &amp; releases</p>
</main>
<footer>
&copy; FlowerCore
</footer>
</body>
</html>

View File

@@ -0,0 +1,195 @@
# FlowerCore.Library.Web GitOps adoption manifest.
#
# Authored from the already-live fc-library resources on 2026-06-04.
# Keep the live image tag, Service ClusterIP, and PVC volumeName unchanged so
# ArgoCD adopts in place instead of replacing the workload or data volume.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: library-web-data
namespace: fc-library
labels:
app.kubernetes.io/name: library-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-library
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: longhorn
volumeMode: Filesystem
volumeName: pvc-2690bae2-4ee0-417a-b95f-50ec5c632b63
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: library-web
namespace: fc-library
labels:
app.kubernetes.io/name: library-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-library
spec:
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/name: library-web
strategy:
type: Recreate
template:
metadata:
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/health"
prometheus.io/path: /metrics/prometheus
prometheus.io/port: "5000"
prometheus.io/scrape: "true"
labels:
app.kubernetes.io/name: library-web
app.kubernetes.io/part-of: flowercore
spec:
containers:
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
- envFrom:
- configMapRef:
name: library-web-config
image: localhost/fc-library-web:v20260602-library-owned-deploy-fix1
imagePullPolicy: Never
livenessProbe:
failureThreshold: 3
httpGet:
path: /health
port: 5000
scheme: HTTP
initialDelaySeconds: 30
periodSeconds: 30
successThreshold: 1
timeoutSeconds: 5
name: library-web
ports:
- containerPort: 5000
name: http
protocol: TCP
readinessProbe:
failureThreshold: 6
httpGet:
path: /health
port: 5000
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /data
name: data
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: library-web-data
---
apiVersion: v1
kind: Service
metadata:
name: library-web
namespace: fc-library
labels:
app.kubernetes.io/name: library-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-library
spec:
clusterIP: 10.43.179.63
clusterIPs:
- 10.43.179.63
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 80
protocol: TCP
targetPort: 5000
selector:
app.kubernetes.io/name: library-web
sessionAffinity: None
type: ClusterIP
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: library-web-tls
namespace: fc-library
labels:
app.kubernetes.io/name: library-web-tls
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-library
spec:
dnsNames:
- library.iamworkin.lan
issuerRef:
kind: ClusterIssuer
name: step-ca-acme
secretName: library-web-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: library-web
namespace: fc-library
labels:
app.kubernetes.io/name: library-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-library
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`library.iamworkin.lan`)
services:
- name: library-web
port: 80
tls:
secretName: library-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose library-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: library-web-public
# namespace: fc-library
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`library.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: library-web-public-profile-header # injects entitlement profile
# services:
# - name: library-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -83,6 +83,8 @@ spec:
app.kubernetes.io/name: fc-llm-bridge
app.kubernetes.io/part-of: flowercore
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
@@ -116,6 +118,7 @@ spec:
ports:
- containerPort: 8080
name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
@@ -281,3 +284,26 @@ spec:
port: 8080
tls:
secretName: fc-llm-bridge-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose fc-llm-bridge publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: fc-llm-bridge-public
# namespace: fc-llm-bridge
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`llm-bridge.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: fc-llm-bridge-public-profile-header # injects entitlement profile
# services:
# - name: fc-llm-bridge
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

296
apps/fc-media/fc-media.yaml Normal file
View File

@@ -0,0 +1,296 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-media
labels:
app.kubernetes.io/name: fc-media
app.kubernetes.io/part-of: flowercore
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: media-oidc-client
namespace: fc-media
labels:
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
spec:
itemPath: "vaults/IAmWorkin/items/media-oidc-client"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-media-config
namespace: fc-media
labels:
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
data:
appsettings.Production.json: |
{
"DatabaseProvider": "Sqlite",
"ConnectionStrings": {
"Sqlite": "Data Source=/data/media.db"
},
"FlowerCore": {
"Auth": {
"Enabled": true,
"Oidc": {
"Authority": "https://id.iamworkin.lan/application/o/media/",
"ClientId": "media",
"ClientSecret": "",
"Audience": "media",
"RequireHttpsMetadata": true
}
},
"Tenant": {
"JwtClaimsEnabled": false,
"DefaultTenantHosts": [ "media.iamworkin.lan" ]
}
},
"Media": {
"LibraryRoot": "/media/library",
"Sources": [
{
"Name": "BlueJayNAS Video",
"Driver": "Nfs",
"MountedPath": "/media/library",
"RemotePath": "nfs://10.0.58.3/volume1/video",
"IsEnabled": true,
"IsDefault": true,
"Notes": "Synology NFS media share mounted read-only inside the cluster."
}
],
"GeneratedRoot": "/data/generated",
"TranscodeRoot": "/data/transcodes",
"InboxPath": "/media/inbox",
"InboxScanIntervalMinutes": 5,
"ScanOnStartup": false,
"ComputeChecksums": false,
"FfmpegCommand": "ffmpeg",
"FfprobeCommand": "ffprobe",
"Hls": {
"MaxConcurrentJobs": 1
},
"DefaultViewerName": "BlueJay",
"Dlna": {
"IsEnabled": true,
"MulticastAddress": "239.255.255.250",
"Port": 1900,
"DiscoveryTimeoutSeconds": 2,
"DescriptionFetchTimeoutSeconds": 2,
"MaxResponsesPerSearchTarget": 32,
"SearchTargets": [
"urn:schemas-upnp-org:device:MediaRenderer:1",
"urn:schemas-upnp-org:device:MediaServer:1"
]
}
}
}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fc-media-data
namespace: fc-media
labels:
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
storageClassName: longhorn
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-media-web
namespace: fc-media
labels:
app: fc-media-web
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: fc-media-web
template:
metadata:
labels:
app: fc-media-web
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "5200"
prometheus.io/path: "/metrics"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec:
nodeSelector:
kubernetes.io/hostname: rke2-server
containers:
- name: fc-media-web
image: localhost/fc-media-web:v20260604-oidc-proper
imagePullPolicy: Never
ports:
- containerPort: 5200
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: Production
- name: ASPNETCORE_URLS
value: http://+:5200
- name: FlowerCore__Auth__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Audience
value: "media"
- name: FlowerCore__Auth__Oidc__ClientId
valueFrom:
secretKeyRef:
name: media-oidc-client
key: client_id
optional: true
- name: FlowerCore__Auth__Oidc__ClientSecret
valueFrom:
secretKeyRef:
name: media-oidc-client
key: client_secret
optional: true
- name: FlowerCore__Auth__Oidc__Authority
valueFrom:
secretKeyRef:
name: media-oidc-client
key: issuer_url
optional: true
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "4"
memory: 4Gi
volumeMounts:
- name: config
mountPath: /app/appsettings.Production.json
subPath: appsettings.Production.json
readOnly: true
- name: data
mountPath: /data
- name: transcodes
mountPath: /data/transcodes
- name: media-library
mountPath: /media/library
readOnly: true
- name: media-inbox
mountPath: /media/inbox
startupProbe:
httpGet:
path: /healthz
port: 5200
httpHeaders:
- name: X-Forwarded-Proto
value: https
failureThreshold: 18
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: 5200
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 5200
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: config
configMap:
name: fc-media-config
- name: data
persistentVolumeClaim:
claimName: fc-media-data
- name: transcodes
nfs:
server: 10.0.58.3
path: /volume1/kubernetes/fc-media-transcodes
- name: media-inbox
nfs:
server: 10.0.58.3
path: /volume1/kubernetes/fc-media-inbox
- name: media-library
nfs:
server: 10.0.58.3
path: /volume1/video
readOnly: true
---
apiVersion: v1
kind: Service
metadata:
name: fc-media-web
namespace: fc-media
labels:
app: fc-media-web
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
spec:
type: ClusterIP
selector:
app: fc-media-web
ports:
- port: 5200
targetPort: 5200
protocol: TCP
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-media-tls
namespace: fc-media
labels:
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
spec:
secretName: fc-media-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- media.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-media-web
namespace: fc-media
labels:
app.kubernetes.io/name: fc-media-web
app.kubernetes.io/part-of: flowercore
spec:
entryPoints:
- websecure
routes:
- match: Host(`media.iamworkin.lan`)
kind: Rule
services:
- name: fc-media-web
port: 5200
tls:
secretName: fc-media-tls

View File

@@ -0,0 +1,6 @@
# ArgoCD's bluejay-infra ApplicationSet discovers apps/* directories on main.
# The kustomization is included for local previews and single-app validation.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- fc-media.yaml

View File

@@ -30,3 +30,26 @@ spec:
port: 80
tls:
secretName: menuboard-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose menuboard-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: menuboard-web-public
# namespace: fc-menuboard
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`menuboard.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: menuboard-web-public-profile-header # injects entitlement profile
# services:
# - name: menuboard-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -41,6 +41,8 @@ spec:
labels:
app: messageboard-web
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/health"
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics/prometheus"
@@ -52,6 +54,7 @@ spec:
ports:
- containerPort: 8080
name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
envFrom:
- configMapRef:
name: messageboard-web-config
@@ -141,3 +144,26 @@ spec:
port: 80
tls:
secretName: messageboard-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose messageboard-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: messageboard-web-public
# namespace: fc-messageboard
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`messageboard.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: messageboard-web-public-profile-header # injects entitlement profile
# services:
# - name: messageboard-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -30,3 +30,26 @@ spec:
port: 5300
tls:
secretName: mysql-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose mysql-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: mysql-web-public
# namespace: fc-mysql
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`mysql.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: mysql-web-public-profile-header # injects entitlement profile
# services:
# - name: mysql-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -0,0 +1,33 @@
# Certificate for network.iamworkin.lan.
#
# Preflight gate: network.iamworkin.lan must resolve to 10.0.56.200 before this
# Certificate is synced. step-ca ACME cannot see the CoreDNS wildcard
# (*.iamworkin.lan -> 10.0.56.200) — it does an HTTP-01 challenge against the
# resolved host. The CoreDNS wildcard template covers network.iamworkin.lan, so
# resolution exists fleet-wide; do NOT add a pfSense DNS override (this plane is
# read-only and holds no pfSense creds). If ACME backs off, confirm the wildcard
# resolves first (feedback_pfsense_dns_required_for_acme).
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-network-web-tls
namespace: fc-network
labels:
app: fc-network-web
app.kubernetes.io/name: fc-network-web
app.kubernetes.io/component: web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
annotations:
flowercore.io/dns-preflight: "network.iamworkin.lan must resolve to 10.0.56.200 (CoreDNS wildcard) before ACME sync"
spec:
secretName: fc-network-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- network.iamworkin.lan
duration: 720h
renewBefore: 240h

View File

@@ -0,0 +1,145 @@
# FlowerCore.Network.Web — the pfSense automation plane (read-only Phase 0, ADR-189).
#
# Phase 0 is READ-ONLY: the service holds NO pfSense credentials and has no write
# path to pfSense anywhere. The only mutating endpoint is POST /api/v1/snapshots,
# which ingests a config.xml the noc1 exporter collected READ-ONLY and stores it
# (redacted projection) on the PVC. Auth ships gate-OFF.
#
# Image localhost/fc-network-web:<tag> is built by FlowerCore.Network
# scripts/deploy-k8s.sh and imported to all schedulable RKE2 nodes (rke2-server +
# rke2-agent1; agent2 retired). imagePullPolicy: Never — bump the tag here, sync
# ArgoCD, then scale 0->1 for the RWO PVC and verify the running pod imageID.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-network-web
namespace: fc-network
labels:
app: fc-network-web
app.kubernetes.io/name: fc-network-web
app.kubernetes.io/component: web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
annotations:
flowercore.io/traceability-standard: k8s-pod-ownership-and-traceability-standard
spec:
replicas: 1
revisionHistoryLimit: 3
# RWO PVC: a single replica can't be surged (the new pod can't mount the volume
# while the old one holds it). maxSurge 0 / maxUnavailable 1 is the rwo-safe shape;
# for image bumps scale 0->1 rather than rollout restart.
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
selector:
matchLabels:
app: fc-network-web
template:
metadata:
labels:
app: fc-network-web
app.kubernetes.io/name: fc-network-web
app.kubernetes.io/component: web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/scrape: "true"
prometheus.io/port: "5340"
prometheus.io/path: "/metrics/prometheus"
flowercore.io/audit-trace-id: "runtime-activity-trace"
spec:
securityContext:
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
image: localhost/fc-network-web:v20260612-0b5b049
imagePullPolicy: Never
ports:
- name: http
containerPort: 5340
# fc-safe-to-expose: read-only plane, auth gate-OFF; X-Forwarded-Proto handled
# by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: ASPNETCORE_URLS
value: "http://+:5340"
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
- name: HOME
value: "/data"
- name: FlowerCore__Auth__Enabled
value: "false"
- name: FlowerCore__Database__Provider
value: "Sqlite"
- name: FlowerCore__Database__ConnectionStrings__Sqlite
value: "Data Source=/data/network.db"
# Snapshot store + intended-model paths MUST be absolute on the PVC —
# the default is relative to the read-only content root.
- name: FlowerCore__Network__SnapshotStore__RootDirectory
value: "/data/snapshots"
- name: FlowerCore__Network__SnapshotStore__UseGitHistory
value: "true"
- name: FlowerCore__Network__IntendedModel__FilePath
value: "/data/intended.json"
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
startupProbe:
httpGet:
path: /healthz
port: 5340
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /healthz
port: 5340
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: 5340
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
volumes:
- name: data
persistentVolumeClaim:
claimName: fc-network-web-data
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}

View File

@@ -0,0 +1,32 @@
# LAN ingress for FlowerCore.Network Web (network.iamworkin.lan).
#
# RKE2 Traefik has no built-in ACME resolver; TLS certificate ownership stays in
# cert-manager Certificate/fc-network-web-tls. Phase 0 is read-only but the POST
# ingest endpoint is genuinely needed by the noc1 exporter, so this route allows
# all methods (no GET/HEAD-only restriction like fc-dns) — the service itself has
# NO pfSense write path, so allowing POST here only reaches the local snapshot
# ingest.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-network-web
namespace: fc-network
labels:
app: fc-network-web
app.kubernetes.io/name: fc-network-web
app.kubernetes.io/component: web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
entryPoints:
- websecure
routes:
- match: Host(`network.iamworkin.lan`)
kind: Rule
services:
- name: fc-network-web
port: 80
tls:
secretName: fc-network-web-tls

View File

@@ -0,0 +1,11 @@
# ArgoCD's bluejay-infra ApplicationSet discovers apps/* directories on main.
# The kustomization is included for local previews and single-app validation.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- pvc.yaml
- deployment-web.yaml
- service-web.yaml
- certificate-web.yaml
- ingressroute-web.yaml

View File

@@ -0,0 +1,8 @@
apiVersion: v1
kind: Namespace
metadata:
name: fc-network
labels:
app.kubernetes.io/part-of: flowercore
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra

27
apps/fc-network/pvc.yaml Normal file
View File

@@ -0,0 +1,27 @@
# Persistent store for FlowerCore.Network (read-only pfSense automation plane).
#
# Holds the SQLite snapshot INDEX db (network.db) AND the on-box snapshot store
# (data/snapshots): full-fidelity raw config.xml + redacted inventory sidecars +
# an on-box git history. Full-fidelity config is on-box ONLY (this PVC); the
# service DB / REST / MCP / UI only ever surface the REDACTED projection.
# RWO — single replica, scale 0->1 for updates (never rollout restart).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fc-network-web-data
namespace: fc-network
labels:
app: fc-network-web
app.kubernetes.io/name: fc-network-web
app.kubernetes.io/component: web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi

View File

@@ -0,0 +1,21 @@
apiVersion: v1
kind: Service
metadata:
name: fc-network-web
namespace: fc-network
labels:
app: fc-network-web
app.kubernetes.io/name: fc-network-web
app.kubernetes.io/component: web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
selector:
app: fc-network-web
ports:
- name: http
port: 80
targetPort: 5340
type: ClusterIP

View File

@@ -30,3 +30,26 @@ spec:
port: 5400
tls:
secretName: php-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose php-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: php-web-public
# namespace: fc-php
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`php.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: php-web-public-profile-header # injects entitlement profile
# services:
# - name: php-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -30,3 +30,26 @@ spec:
port: 80
tls:
secretName: presentations-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose presentations-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: presentations-web-public
# namespace: fc-presentations
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`presentations.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: presentations-web-public-profile-header # injects entitlement profile
# services:
# - name: presentations-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -0,0 +1,196 @@
# FlowerCore.Retail.Web GitOps adoption manifest.
#
# Authored from the already-live fc-retail resources on 2026-06-04.
# Keep the live image tag, Service ClusterIP, and PVC volumeName unchanged so
# ArgoCD adopts in place instead of replacing the workload or data volume.
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: retail-web-data
namespace: fc-retail
labels:
app.kubernetes.io/name: retail-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-retail
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: longhorn
volumeMode: Filesystem
volumeName: pvc-3d40b336-eab4-41b3-812c-d5e9413ce0ab
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: retail-web
namespace: fc-retail
labels:
app.kubernetes.io/name: retail-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-retail
spec:
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/name: retail-web
strategy:
type: Recreate
template:
metadata:
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
kubectl.kubernetes.io/restartedAt: "2026-06-02T01:34:08-05:00"
prometheus.io/path: /metrics/prometheus
prometheus.io/port: "5000"
prometheus.io/scrape: "true"
labels:
app.kubernetes.io/name: retail-web
app.kubernetes.io/part-of: flowercore
spec:
containers:
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
- envFrom:
- configMapRef:
name: retail-web-config
image: localhost/fc-retail-web:v20260602-retail-owned-deploy-fix5
imagePullPolicy: Never
livenessProbe:
failureThreshold: 3
httpGet:
path: /health
port: 5000
scheme: HTTP
initialDelaySeconds: 30
periodSeconds: 30
successThreshold: 1
timeoutSeconds: 5
name: retail-web
ports:
- containerPort: 5000
name: http
protocol: TCP
readinessProbe:
failureThreshold: 6
httpGet:
path: /health
port: 5000
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /data
name: data
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: retail-web-data
---
apiVersion: v1
kind: Service
metadata:
name: retail-web
namespace: fc-retail
labels:
app.kubernetes.io/name: retail-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-retail
spec:
clusterIP: 10.43.239.8
clusterIPs:
- 10.43.239.8
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: http
port: 80
protocol: TCP
targetPort: 5000
selector:
app.kubernetes.io/name: retail-web
sessionAffinity: None
type: ClusterIP
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: retail-web-tls
namespace: fc-retail
labels:
app.kubernetes.io/name: retail-web-tls
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-retail
spec:
dnsNames:
- retail.iamworkin.lan
issuerRef:
kind: ClusterIssuer
name: step-ca-acme
secretName: retail-web-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: retail-web
namespace: fc-retail
labels:
app.kubernetes.io/name: retail-web
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
argocd.argoproj.io/instance: infra-fc-retail
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`retail.iamworkin.lan`)
services:
- name: retail-web
port: 80
tls:
secretName: retail-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose retail-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: retail-web-public
# namespace: fc-retail
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`retail.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: retail-web-public-profile-header # injects entitlement profile
# services:
# - name: retail-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -30,3 +30,26 @@ spec:
port: 80
tls:
secretName: scoreboard-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose scoreboard-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: scoreboard-web-public
# namespace: fc-scoreboard
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`scoreboard.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: scoreboard-web-public-profile-header # injects entitlement profile
# services:
# - name: scoreboard-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -37,3 +37,26 @@ spec:
port: 80
tls:
secretName: segmentdisplay-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose segmentdisplay-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: segmentdisplay-web-public
# namespace: fc-segmentdisplay
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`segmentdisplay.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: segmentdisplay-web-public-profile-header # injects entitlement profile
# services:
# - name: segmentdisplay-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -46,3 +46,26 @@ spec:
services:
- name: signage-web
port: 5190
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose signage-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: signage-web-public
# namespace: fc-signage
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`signage.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: signage-web-public-profile-header # injects entitlement profile
# services:
# - name: signage-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -1,33 +0,0 @@
# FlowerCore SignalControl platform notes
This app owns the cluster web manager at `signalcontrol.iamworkin.lan` and documents the physical Pi pilot at `signal-a.iamworkin.lan` / `pirelay`.
## mTLS enrollment pattern
Do not install or restart anything from this repo. The intended pirelay pattern is the Pi-signage step-ca-agent shape:
- stable node identity: `pirelay`
- local private key and CSR generated on the node
- CSR submitted through the approved DeviceManagement/step-ca enrollment path
- client certificate and chain stored node-local under `/etc/flowercore/signalcontrol/mtls/`
- daily renewal timer, renewing only when fewer than 30 days remain
- certificate used for DM-agent to DM-web traffic and future SignalControl inter-service calls
Secrets, enrollment codes, private keys, p12 passphrases, and OIDC client secrets stay out of Git.
## Telemetry
Monitoring manifests add a dedicated Prometheus job:
- `signalcontrol-pi-app`
- target `10.0.58.113:5200`
- path `/metrics/prometheus`
- labels `instance="pirelay"`, `host="signal-a.iamworkin.lan"`, `service="signalcontrol-pi"`
Host metrics continue through the `edge-nodes` node_exporter target at `10.0.58.113:9100`.
## Physical-control audit
The app ships with `FlowerCore:SignalControl:PhysicalAudit:Enabled=false` and `ForwardingEnabled=false`. Enabling local audit creates a SHA-256 hash chain for physical-control mutations. Forwarding to `https://audit.iamworkin.lan/api/v1/audit/signalcontrol` requires flipping the forwarding gate separately.
Telemetry reads and `/metrics` scrapes are not audited.

View File

@@ -46,7 +46,7 @@ spec:
spec:
containers:
- name: signalcontrol-web
image: localhost/fc-signalcontrol-web:s50cx12-20260602-1d26c58
image: localhost/fc-signalcontrol-web:latest
imagePullPolicy: Never
ports:
- containerPort: 5000
@@ -65,48 +65,6 @@ spec:
secretKeyRef:
name: signalcontrol-auth
key: Auth__ApiKey
- name: Auth__AdminApiKey
valueFrom:
secretKeyRef:
name: signalcontrol-auth
key: Auth__AdminApiKey
optional: true
- name: Auth__Enabled
value: "false"
- name: FlowerCore__Auth__Enabled
value: "false"
- name: FlowerCore__Auth__Oidc__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Authority
valueFrom:
secretKeyRef:
name: signalcontrol-oidc-client
key: issuer_url
optional: true
- name: FlowerCore__Auth__Oidc__ClientId
valueFrom:
secretKeyRef:
name: signalcontrol-oidc-client
key: client_id
optional: true
- name: FlowerCore__Auth__Oidc__ClientSecret
valueFrom:
secretKeyRef:
name: signalcontrol-oidc-client
key: client_secret
optional: true
- name: TrafficSignal__RelayBridge__Enabled
value: "true"
- name: TrafficSignal__RelayBridge__BaseUrl
value: https://pirelay.iamworkin.lan
- name: TrafficSignal__RelayBridge__ApiKey
valueFrom:
secretKeyRef:
name: signalcontrol-pirelay
key: ApiKey
optional: true
- name: LiveStatus__TrafficSignal__BaseAddress
value: https://signalcontrol.iamworkin.lan
volumeMounts:
- name: data
mountPath: /data

View File

@@ -97,6 +97,7 @@ spec:
containers:
- name: piper
image: rhasspy/wyoming-piper:latest
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: PYTHONHTTPSVERIFY
value: "0"
@@ -523,6 +524,8 @@ spec:
app.kubernetes.io/name: ttsreader-web
app.kubernetes.io/part-of: flowercore
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/health"
prometheus.io/scrape: "true"
prometheus.io/port: "5217"
prometheus.io/path: "/metrics"
@@ -532,7 +535,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
image: localhost/fc-ttsreader-web:v20260531-tts-corrections-r2
image: localhost/fc-ttsreader-web:v20260614-wave5-help-2f096e3
imagePullPolicy: Never
ports:
- containerPort: 5217
@@ -762,3 +765,26 @@ spec:
port: 5217
tls:
secretName: ttsreader-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose ttsreader-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: ttsreader-web-public
# namespace: fc-ttsreader
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`ttsreader.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: ttsreader-web-public-profile-header # injects entitlement profile
# services:
# - name: ttsreader-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -52,6 +52,9 @@ spec:
app: updatecenter-web
template:
metadata:
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/"
labels:
app: updatecenter-web
spec:
@@ -63,6 +66,7 @@ spec:
ports:
- containerPort: 8080
name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: ASPNETCORE_URLS
value: http://+:8080

View File

@@ -0,0 +1,63 @@
FROM myoung34/github-runner:latest
# arm64 variant of the FlowerCore self-hosted runner image.
# Built/maintained alongside the amd64 Dockerfile. The ONLY substantive
# difference from the amd64 image is that the Actions/Ruby tool-cache arch
# label is "arm64" instead of the amd64 "x64". Ruby itself is still compiled
# from source via ruby-build, so it is naturally arm64 on an aarch64 host.
ARG RUBY_VERSION=3.3.11
ARG RUBY_MINOR=3.3
ARG RUBY_BUILD_VERSION=v20260326
ARG RUNNER_UID=1001
ARG RUNNER_GID=1001
# arm64 tool-cache arch label (was x64 on amd64). ruby/setup-ruby@v1 on a
# self-hosted aarch64 Linux runner discovers Ruby under _tool/Ruby/<ver>/arm64.
ARG TOOLCACHE_ARCH=arm64
ENV RUNNER_TOOL_CACHE=/home/runner/_tool
ENV RUNNER_RUBY_TOOLCACHE=/opt/runner-toolcache
ENV PATH="/home/runner/_tool/Ruby/${RUBY_MINOR}/${TOOLCACHE_ARCH}/bin:/opt/runner-toolcache/Ruby/${RUBY_MINOR}/${TOOLCACHE_ARCH}/bin:${PATH}"
USER root
# Bake the IAmWorkin step-ca root CA into the system trust store. Without
# this, .NET HttpClient calls from CI tests against *.iamworkin.lan
# (e.g. https://selenium.iamworkin.lan/session) fail with `PartialChain`
# because the runner image's default Ubuntu trust bundle doesn't include
# our internal Root CA. update-ca-certificates regenerates
# /etc/ssl/certs/ca-certificates.crt, which OpenSSL + .NET on Linux read
# automatically — no SSL_CERT_FILE env var needed.
COPY step-ca-root.crt /usr/local/share/ca-certificates/iamworkin-step-ca-root.crt
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
autoconf \
bison \
build-essential \
ca-certificates \
curl \
libdb-dev \
libffi-dev \
libgdbm-dev \
libgmp-dev \
libncurses-dev \
libreadline-dev \
libssl-dev \
libyaml-dev \
patch \
pkg-config \
uuid-dev \
zlib1g-dev \
&& update-ca-certificates \
&& curl -fsSL "https://github.com/rbenv/ruby-build/archive/refs/tags/${RUBY_BUILD_VERSION}.tar.gz" -o /tmp/ruby-build.tar.gz \
&& mkdir -p /tmp/ruby-build \
&& tar -xzf /tmp/ruby-build.tar.gz --strip-components=1 -C /tmp/ruby-build \
&& /tmp/ruby-build/install.sh \
&& rm -rf /tmp/ruby-build /tmp/ruby-build.tar.gz /var/lib/apt/lists/*
COPY install-ruby-toolcache.sh /usr/local/bin/install-ruby-toolcache.sh
RUN chmod +x /usr/local/bin/install-ruby-toolcache.sh \
&& RUBY_VERSION="${RUBY_VERSION}" RUBY_MINOR="${RUBY_MINOR}" TOOLCACHE_ARCH="${TOOLCACHE_ARCH}" TOOLCACHE_ROOT="${RUNNER_RUBY_TOOLCACHE}" RUNNER_UID="${RUNNER_UID}" RUNNER_GID="${RUNNER_GID}" /usr/local/bin/install-ruby-toolcache.sh \
&& ruby -v

View File

@@ -12,6 +12,8 @@ All repo-scoped Linux runners use:
- `ACCESS_TOKEN` from the `github-runner-token` Secret
- `RUN_AS_ROOT=false`
- `EPHEMERAL=true`
- `DISABLE_AUTO_UPDATE=true` so the runner does not self-update and exit inside
the immutable Kubernetes pod
- `LABELS=self-hosted,linux,fc-build-linux`
- writable non-root paths under `/home/runner` for .NET, NuGet, XDG cache, and
Actions tool cache
@@ -131,3 +133,7 @@ from GitHub Actions and verify it lands on an `rke2-linux-*` runner.
value does not change.
- `Multi-Attach` volume error: only the Common runner uses a RWO PVC and it must
stay single-replica. New multi-replica runners use `emptyDir`.
- Runner pods repeatedly registering, downloading a newer Actions runner, then
exiting with code 4: verify `DISABLE_AUTO_UPDATE=true` is present. The image
translates that into `config.sh --disableupdate`; without it, the Deployment
controller sees the expected self-update exit as CrashLoopBackOff.

View File

@@ -195,6 +195,11 @@ spec:
# fresh registration occurs. Prevents stale runner accumulation.
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
# Labels used by workflow files: runs-on: [self-hosted, linux, fc-build-linux]
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
@@ -366,6 +371,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -504,6 +514,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -636,6 +651,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -768,6 +788,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -900,6 +925,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1035,6 +1065,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1167,6 +1202,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1299,6 +1339,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1431,6 +1476,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1565,6 +1615,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1699,6 +1754,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1838,6 +1898,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -1972,6 +2037,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -2106,6 +2176,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -2240,6 +2315,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -2373,6 +2453,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -2507,6 +2592,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -2640,6 +2730,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -2773,6 +2868,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -2906,6 +3006,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3039,6 +3144,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3172,6 +3282,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3306,6 +3421,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3440,6 +3560,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3574,6 +3699,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3708,6 +3838,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3842,6 +3977,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -3975,6 +4115,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -4109,6 +4254,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -4247,6 +4397,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -4386,6 +4541,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME
@@ -4521,6 +4681,11 @@ spec:
value: "/tmp/runner/work"
- name: EPHEMERAL
value: "true"
# The runner image must not self-update inside an immutable
# Kubernetes pod. Without this, GitHub runner auto-update exits
# with code 4 and the Deployment falls into CrashLoopBackOff.
- name: DISABLE_AUTO_UPDATE
value: "true"
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
- name: HOME

View File

@@ -6,12 +6,16 @@ RUBY_MINOR="${RUBY_MINOR:-3.3}"
TOOLCACHE_ROOT="${TOOLCACHE_ROOT:-/opt/runner-toolcache}"
RUNNER_UID="${RUNNER_UID:-1001}"
RUNNER_GID="${RUNNER_GID:-1001}"
RUBY_PREFIX="${TOOLCACHE_ROOT}/Ruby/${RUBY_VERSION}/x64"
# Tool-cache arch label. Defaults to x64 (amd64 image, unchanged). The arm64
# Dockerfile passes TOOLCACHE_ARCH=arm64 so ruby/setup-ruby@v1 finds Ruby on
# aarch64 self-hosted runners.
TOOLCACHE_ARCH="${TOOLCACHE_ARCH:-x64}"
RUBY_PREFIX="${TOOLCACHE_ROOT}/Ruby/${RUBY_VERSION}/${TOOLCACHE_ARCH}"
mkdir -p "${TOOLCACHE_ROOT}/Ruby"
RUBY_CONFIGURE_OPTS="${RUBY_CONFIGURE_OPTS:---disable-install-doc --disable-yjit}" ruby-build "${RUBY_VERSION}" "${RUBY_PREFIX}"
touch "${TOOLCACHE_ROOT}/Ruby/${RUBY_VERSION}/x64.complete"
touch "${TOOLCACHE_ROOT}/Ruby/${RUBY_VERSION}/${TOOLCACHE_ARCH}.complete"
ln -sfn "${RUBY_VERSION}" "${TOOLCACHE_ROOT}/Ruby/${RUBY_MINOR}"
"${RUBY_PREFIX}/bin/ruby" -v

View File

@@ -44,9 +44,32 @@ spec:
labels:
app: intranet-web
spec:
# notes-corpus-clone: shallow-clones the Notes docs corpus into an emptyDir so
# the IntranetSearch indexer has /srv/flowercore-notes/docs to index. Uses the
# trailing-dot FQDN (gitea-clusterip.gitea.svc.cluster.local.) to bypass the
# CoreDNS *.iamworkin.lan template that otherwise resolves the in-cluster service
# name to the Traefik VIP for musl / ndots:5 pods (search-domain appending).
# Cred: gitea-corpus-cred (in-ns secret with the canonical 1P bluejay read cred;
# mirrors the imperative gitea-flowercore-notes argocd repo-cred pattern).
initContainers:
- name: notes-corpus-clone
image: alpine/git:2.45.2
imagePullPolicy: IfNotPresent
envFrom:
- secretRef:
name: gitea-corpus-cred
env:
- name: GIT_LFS_SKIP_SMUDGE
value: "1"
command: ["/bin/sh", "-c"]
args:
- 'git clone --depth 1 http://$username:$password@gitea-clusterip.gitea.svc.cluster.local.:3000/bluejay/FlowerCore.Notes.git /srv/flowercore-notes && echo "notes corpus cloned; docs entries:" && ls /srv/flowercore-notes/docs | wc -l'
volumeMounts:
- name: notes-corpus
mountPath: /srv/flowercore-notes
containers:
- name: intranet-web
image: localhost/fc-intranet-web:v20260531-ttsreader-bridge
image: localhost/fc-intranet-web:v20260614-wave5-knowledgefleet-1458b4d
imagePullPolicy: Never
ports:
- containerPort: 5300
@@ -56,18 +79,32 @@ spec:
value: Production
- name: ASPNETCORE_URLS
value: "http://+:5300"
# Bulk corpus indexing on edge1 Pi 5 takes ~6s/chunk × 5665 chunks
# ≈ 9 hours. BLUEJAY-WS GPU (R9700, 32GB VRAM) does the same work
# in minutes. Memory: feedback_pi5_nomic_embed_slow.
# Embed backend = edge1 Ollama BY IPv4 (10.0.57.17:11434; has
# nomic-embed-text). The hostname edge1.iamworkin.lan is UNUSABLE from
# cluster pods: it resolves to an unroutable IPv6 (fdbc:56:*) and the
# CoreDNS *.iamworkin.lan template maps the name to the Traefik VIP, so
# embeds failed with "No route to host". Use a bare pod-routable IPv4.
# Backend is BLUEJAY-AI's GPU node (Ollama / Vulkan Iris Xe, INFRA VLAN
# 10.0.56.132) which embeds nomic-embed-text in ~160ms vs the edge1 Pi 5's
# ~3.2s for the same ~512-token chunk (~20x faster bulk embed), proven
# pod-routable from the intranet namespace 2026-06-13. The prior edge1 Pi 5
# backend (10.0.57.17:11434) remains a working fallback if BLUEJAY-AI is
# down. Bulk embed runs in the background; /health does not depend on it.
# Memory: feedback_pi5_nomic_embed_slow.
- name: IntranetSearch__OllamaBaseUrl
value: "http://10.0.56.20:11434"
# Sprint E Phase 2α — JSON-file-backed PageReadingOverride persistence
# on the writable PVC at /data. Without this env var the
# intranet falls back to the in-memory store (loses state on
# pod restart). Master's PageReadingOverrideOptions binds
# PageReadingOverrides:FilePath.
- name: PageReadingOverrides__FilePath
value: "/data/page-reading-overrides.json"
value: "http://10.0.56.132:11434"
# Notes docs corpus IS now mounted at /srv/flowercore-notes (see the
# notes-corpus-clone initContainer + notes-corpus-sync sidecar), so the
# IntranetSearch indexer is ENABLED. First-boot bulk embed of the corpus
# runs in the background via the edge1 Ollama backend above (~6s/chunk on
# the Pi 5); /health readiness does not depend on it, so the pod stays Ready.
- name: IntranetSearch__Enabled
value: "true"
# Page-reading override SQLite persistence on the writable PVC at
# /data. This backs pronunciation, notes, corrections, and
# page-profile metadata across pod restarts.
- name: PageReadingOverrides__DatabasePath
value: "/data/page-reading-overrides.db"
- name: KnowledgeFleetSearch__BaseUrl
value: "https://knowledge.iamworkin.lan"
- name: KnowledgeFleetSearch__ApiKey
@@ -104,10 +141,40 @@ spec:
volumeMounts:
- name: vector-store
mountPath: /data
- name: notes-corpus
mountPath: /srv/flowercore-notes
readOnly: true
# notes-corpus-sync: keeps the mounted corpus fresh between pod restarts by
# pulling the Notes repo every 30 min (best-effort; the initContainer guarantees
# a fresh clone at pod start). Reuses the clone's origin (trailing-dot host + creds).
- name: notes-corpus-sync
image: alpine/git:2.45.2
imagePullPolicy: IfNotPresent
envFrom:
- secretRef:
name: gitea-corpus-cred
env:
- name: GIT_LFS_SKIP_SMUDGE
value: "1"
command: ["/bin/sh", "-c"]
args:
- 'while true; do sleep 1800; git -C /srv/flowercore-notes pull --depth 1 2>&1 | sed "s/^/[notes-corpus-sync] /" || true; done'
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "128Mi"
cpu: "200m"
volumeMounts:
- name: notes-corpus
mountPath: /srv/flowercore-notes
volumes:
- name: vector-store
persistentVolumeClaim:
claimName: intranet-vector-store
- name: notes-corpus
emptyDir: {}
---
apiVersion: v1
kind: Service

View File

@@ -90,9 +90,12 @@ spec:
app.kubernetes.io/name: knowledge-web
app.kubernetes.io/part-of: bluejay-infra
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec:
securityContext:
runAsNonRoot: true
@@ -102,7 +105,7 @@ spec:
- name: web
# Placeholder tag — bump to the image you built + imported to ALL
# RKE2 nodes via scripts/deploy-knowledge.sh before applying.
image: localhost/fc-knowledge-web:v20260429232635
image: localhost/fc-knowledge-web:v20260603-oidc-authentik-auditfix
imagePullPolicy: Never
command:
- /bin/sh
@@ -116,6 +119,7 @@ spec:
ports:
- containerPort: 8080
name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
@@ -123,6 +127,25 @@ spec:
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
# AuthentiK/OIDC is enforced. /healthz stays anonymous by contract;
# see flowercore.io/healthz-auth-policy above and the Sprint 58
# OIDC readiness probe audit.
- name: FlowerCore__Auth__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Authority
value: "https://id.iamworkin.lan/application/o/knowledge/"
- name: FlowerCore__Auth__Oidc__Audience
value: "knowledge"
- name: FlowerCore__Auth__Oidc__ClientId
value: "knowledge"
- name: FlowerCore__Auth__Oidc__ClientSecret
valueFrom:
secretKeyRef:
name: knowledge-oidc-client
key: client_secret
optional: true
# Vector-store directory + embedding model + edition profile dir.
# Profile JSON is baked into the image at /home/app/editions via the
# csproj Content-link from FlowerCore.Common/editions/.
@@ -134,6 +157,8 @@ spec:
value: "5"
- name: Knowledge__MaxLimit
value: "50"
- name: Knowledge__Federation__DatabasePath
value: "/data/vector-stores/knowledge-federation.db"
- name: FlowerCore__Editions__ProfileDirectory
value: "/home/app/editions"
# Embed via edge1 Pi 5 + AI HAT+ (10.0.57.17:11434). Cluster
@@ -264,3 +289,26 @@ spec:
port: 80
tls:
secretName: knowledge-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose knowledge-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: knowledge-web-public
# namespace: knowledge
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`knowledge.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: knowledge-web-public-profile-header # injects entitlement profile
# services:
# - name: knowledge-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -1,260 +0,0 @@
# Grafana dashboard ConfigMap for FlowerCore.SignalControl on pirelay.
#
# The Grafana Deployment in noc-monitoring.yaml mounts this ConfigMap at
# /var/lib/grafana/dashboards/signalcontrol. The paired Prometheus jobs are:
# - signalcontrol-pi-app: 10.0.58.113:5200 /metrics/prometheus
# - edge-nodes: 10.0.58.113:9100 with instance="pirelay"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-signalcontrol
namespace: monitoring
data:
signalcontrol.json: |
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false },
"textMode": "auto"
},
"targets": [
{ "editorMode": "code", "expr": "up{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}", "range": true, "refId": "A" }
],
"title": "SignalControl App Up",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false },
"textMode": "auto"
},
"targets": [
{ "editorMode": "code", "expr": "up{job=\"edge-nodes\",instance=\"pirelay\"}", "range": true, "refId": "A" }
],
"title": "pirelay node_exporter Up",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false },
"textMode": "name"
},
"targets": [
{ "editorMode": "code", "expr": "signalcontrol_active_pattern{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}", "legendFormat": "{{pattern}}", "range": true, "refId": "A" }
],
"title": "Active Pattern",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false },
"textMode": "name"
},
"targets": [
{ "editorMode": "code", "expr": "signalcontrol_phase{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}", "legendFormat": "{{phase}}", "range": true, "refId": "A" }
],
"title": "Current Phase",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "ops" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
"id": 5,
"options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "single" } },
"targets": [
{
"editorMode": "code",
"expr": "sum by (channel, state) (rate(signal_relay_writes_total{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}[$__rate_interval]))",
"legendFormat": "channel {{channel}} {{state}}",
"range": true,
"refId": "A"
}
],
"title": "Relay Activations",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "ops" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 },
"id": 6,
"options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "single" } },
"targets": [
{
"editorMode": "code",
"expr": "sum by (source, to) (rate(signal_transitions_total{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}[$__rate_interval]))",
"legendFormat": "{{source}} -> {{to}}",
"range": true,
"refId": "A"
}
],
"title": "Phase Dwell / Transitions",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 },
"id": 7,
"options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "single" } },
"targets": [
{
"editorMode": "code",
"expr": "sum by (action, outcome) (increase(signal_schedule_fires_total{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}[24h]))",
"legendFormat": "{{action}} {{outcome}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "sum by (from_pattern, to_pattern) (increase(flowercore_signalcontrol_pattern_switches_total{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}[24h]))",
"legendFormat": "{{from_pattern}} -> {{to_pattern}}",
"range": true,
"refId": "B"
}
],
"title": "Schedule Fires and Pattern Switches",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
"id": 8,
"options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "single" } },
"targets": [
{
"editorMode": "code",
"expr": "1 - avg by (instance) (rate(node_cpu_seconds_total{job=\"edge-nodes\",instance=\"pirelay\",mode=\"idle\"}[$__rate_interval]))",
"legendFormat": "CPU",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "1 - (node_memory_MemAvailable_bytes{job=\"edge-nodes\",instance=\"pirelay\"} / node_memory_MemTotal_bytes{job=\"edge-nodes\",instance=\"pirelay\"})",
"legendFormat": "Memory",
"range": true,
"refId": "B"
}
],
"title": "pirelay Host Utilization",
"type": "timeseries"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 21 },
"id": 9,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false },
"textMode": "auto"
},
"targets": [
{ "editorMode": "code", "expr": "signalcontrol_screen_saver_enabled{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}", "range": true, "refId": "A" }
],
"title": "Screen-saver Enabled",
"type": "stat"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 21 },
"id": 10,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false },
"textMode": "name"
},
"targets": [
{ "editorMode": "code", "expr": "signalcontrol_animation_active{job=\"signalcontrol-pi-app\",instance=\"pirelay\"}", "legendFormat": "{{planner}}", "range": true, "refId": "A" }
],
"title": "Screen-saver / Animation Engaged",
"type": "stat"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": [ "flowercore", "signalcontrol", "pirelay" ],
"templating": { "list": [] },
"time": { "from": "now-24h", "to": "now" },
"timezone": "browser",
"title": "FlowerCore SignalControl",
"uid": "flowercore-signalcontrol",
"version": 1
}

View File

@@ -216,32 +216,24 @@ data:
- job_name: "pimanager-app"
scrape_interval: 15s
metrics_path: /metrics
scheme: https
tls_config:
insecure_skip_verify: true
static_configs:
- targets: ["10.0.58.25:5000"]
- targets: ["piez.iamworkin.lan"]
labels:
instance: "piez"
service: "pimanager"
service: "signalcontrol"
vlan: "home"
device: "pi4-ezconnect"
- targets: ["10.0.58.113:5100"]
rig: "signal-b"
- targets: ["pirelay.iamworkin.lan"]
labels:
instance: "pirelay"
service: "pimanager"
vlan: "home"
device: "pi3-ks0212"
# SignalControl Pi-edition app metrics (pirelay / signal-a)
- job_name: "signalcontrol-pi-app"
scrape_interval: 15s
metrics_path: /metrics/prometheus
static_configs:
- targets: ["10.0.58.113:5200"]
labels:
instance: "pirelay"
host: "signal-a.iamworkin.lan"
service: "signalcontrol-pi"
service: "signalcontrol"
vlan: "home"
device: "pi3-ks0212"
rig: "signal-a"
# Epson ET-3750 EcoTank Printer SNMP
- job_name: "snmp-printer"
@@ -494,22 +486,31 @@ data:
- "https://intranet.iamworkin.lan/"
- "https://signage.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200
- "https://kiosk.iamworkin.lan/"
- "https://media.iamworkin.lan/"
- "https://media.iamworkin.lan/healthz" # root auth-gated by OIDC; /healthz anonymous 200
- "https://mysql.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200
- "https://php.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200
- "https://zabbix.iamworkin.lan/"
- "https://desktop.iamworkin.lan/"
- "https://print.iamworkin.lan/"
- "https://dns.iamworkin.lan/"
- "https://chat.iamworkin.lan/"
- "https://dist.iamworkin.lan/"
- "https://dms.iamworkin.lan/"
- "https://print.iamworkin.lan/healthz" # root 401 behind API key auth; /healthz anonymous 200
- "https://dns.iamworkin.lan/healthz" # root auth-gated by OIDC; /healthz anonymous 200
- "https://signalcontrol.iamworkin.lan/health" # FlowerCore.SignalControl Pi control plane
- "https://flowercore.iamworkin.lan/healthz" # FlowerCore landing
- "https://replay.iamworkin.lan/healthz" # FlowerCore.Signage replay surface
- "https://worldbuilder.iamworkin.lan/healthz" # FlowerCore.WorldBuilder
- "https://updates.iamworkin.lan/api/v1/manifests/_schema" # UpdateCenter plural LAN alias
- "https://updatecenter-internal.iamworkin.lan/api/v1/manifests/_schema" # internal UC schema route
- "https://chat.iamworkin.lan/healthz" # OIDC staged; keep blackbox off root before enforcement flips
- "https://dist.iamworkin.lan/healthz" # root/admin auth-gated by OIDC; /healthz anonymous 200
- "https://dms.iamworkin.lan/healthz" # future OIDC posture; health route is already anonymous/live
- "https://menuboard.iamworkin.lan/"
- "https://messageboard.iamworkin.lan/"
- "https://presentations.iamworkin.lan/"
- "https://retail.iamworkin.lan/"
- "https://ttsreader.iamworkin.lan/"
# Explicit healthcheck paths
- "https://library.iamworkin.lan/health"
- "https://aistation.iamworkin.lan/healthz"
- "https://knowledge.iamworkin.lan/healthz"
- "https://fc-llm-bridge.iamworkin.lan/healthz"
- "https://acme.iamworkin.lan/health"
# NOTE: services intentionally NOT in this probe surface
@@ -921,12 +922,13 @@ data:
# of idle and SNMP times out, so 5m for: would page nightly. A
# genuine printer outage (jam, disconnected) lasts well over 30m.
- alert: EpsonPrinterDown
expr: up{job="snmp-printer"} == 0
expr: (max_over_time(up{job="snmp-printer"}[35m]) == bool 0) == 1 and (hour() >= 13 or hour() < 1)
for: 30m
labels:
severity: warning
severity: info
alert_channel: irc
annotations:
summary: "Epson ET-3750 SNMP unreachable for >30m (likely actual fault, not sleep)"
summary: "Epson ET-3750 SNMP unreachable during waking hours (30m)"
- alert: SynologyDiskLow
expr: hrStorageUsed{job="snmp-nas"} / hrStorageSize{job="snmp-nas"} * 100 > 85
@@ -1033,7 +1035,12 @@ data:
- name: kubernetes-state
rules:
- alert: KubeContainerRestartingFrequently
expr: increase(kube_pod_container_status_restarts_total[1h]) > 5
# Exclude github-runner: ephemeral runners register, run one job,
# exit cleanly, and restart by design. Also require kube_pod_info so
# deleted rollout pods do not keep firing from retained restart series.
expr: |
increase(kube_pod_container_status_restarts_total{namespace!="github-runner"}[1h]) > 5
and on(namespace, pod) kube_pod_info
for: 15m
labels:
severity: warning
@@ -1042,7 +1049,12 @@ data:
description: "Container {{ $labels.container }} in pod {{ $labels.namespace }}/{{ $labels.pod }} has restarted {{ $value | printf \"%.0f\" }} times in the last hour. Check 'kubectl describe pod' + last-state termination reason."
- alert: KubeContainerCrashLooping
expr: increase(kube_pod_container_status_restarts_total[15m]) > 3
# Same github-runner/delete-retention exclusions as the hourly
# restart rule above; real runner failures are covered by the
# dedicated LinuxRunnerOffline/MacMiniRunnerOffline alerts.
expr: |
increase(kube_pod_container_status_restarts_total{namespace!="github-runner"}[15m]) > 3
and on(namespace, pod) kube_pod_info
for: 5m
labels:
severity: critical
@@ -1070,7 +1082,10 @@ data:
description: "Pod can't pull image. Check the image ref (often a stale tag or unreachable registry) and clean up if it's an orphan."
- alert: KubeDeploymentReplicasMismatch
expr: kube_deployment_spec_replicas != kube_deployment_status_replicas_available
# github-runner has explicit runner-offline alerts; the generic
# replica-mismatch rule should not page on intentionally ephemeral
# 0/1 runner churn between CI jobs.
expr: kube_deployment_spec_replicas{namespace!="github-runner"} != kube_deployment_status_replicas_available{namespace!="github-runner"}
for: 15m
labels:
severity: warning
@@ -4064,9 +4079,6 @@ spec:
- name: dashboards-remotedesktop
mountPath: /var/lib/grafana/dashboards/remotedesktop
readOnly: true
- name: dashboards-signalcontrol
mountPath: /var/lib/grafana/dashboards/signalcontrol
readOnly: true
- name: datasource-provisioning
mountPath: /etc/grafana/provisioning/datasources
readOnly: true
@@ -4120,9 +4132,6 @@ spec:
- name: dashboards-remotedesktop
configMap:
name: grafana-dashboard-remotedesktop
- name: dashboards-signalcontrol
configMap:
name: grafana-dashboard-signalcontrol
- name: datasource-provisioning
configMap:
name: grafana-datasource-provisioning

View File

@@ -114,6 +114,9 @@ spec:
app: telephony-web
template:
metadata:
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/health"
labels:
app: telephony-web
spec:
@@ -161,6 +164,7 @@ spec:
ports:
- containerPort: 5100
name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: Telephony__Twilio__AccountSid
valueFrom:
@@ -387,4 +391,3 @@ spec:

View File

@@ -12,28 +12,27 @@ Source: `D:\git\FlowerCore\FlowerCore.WorldBuilder` (master)
in pfSense Unbound before this manifest is applied, or cert-manager
HTTP-01 silently exponential-backs-off ~2h.
Memory: `feedback_pfsense_dns_required_for_acme`.
2. **Image import to ALL RKE2 nodes** — pod can schedule to any of
`rke2-server` (10.0.56.11), `rke2-agent1` (10.0.56.12),
`rke2-agent2` (10.0.56.13). Build with:
2. **Image import to ALL Ready RKE2 nodes** — pod can currently schedule to
`rke2-server` (10.0.56.11) and `rke2-agent1` (10.0.56.12). Build with:
```bash
bash deploy/build.sh # in FlowerCore.WorldBuilder repo
podman save localhost/fc-worldbuilder:v<TAG> -o /tmp/fc-worldbuilder-v<TAG>.tar
for h in 10.0.56.11 10.0.56.12 10.0.56.13; do
scp /tmp/fc-worldbuilder-v<TAG>.tar fcadmin@$h:/tmp/
mkdir -p artifacts/deploy
podman save localhost/fc-worldbuilder:v<TAG> -o artifacts/deploy/fc-worldbuilder-v<TAG>.tar
for h in 10.0.56.11 10.0.56.12; do
ssh fcadmin@$h "mkdir -p /home/fcadmin/.fcv"
scp artifacts/deploy/fc-worldbuilder-v<TAG>.tar fcadmin@$h:/home/fcadmin/.fcv/
ssh fcadmin@$h \
"sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock \
-n k8s.io images import /tmp/fc-worldbuilder-v<TAG>.tar"
-n k8s.io images import /home/fcadmin/.fcv/fc-worldbuilder-v<TAG>.tar"
done
```
Memory: `feedback_rke2_image_import_per_node_scp`.
3. **Bump image tag** in `worldbuilder.yaml` and git push.
ArgoCD ApplicationSet picks up within ~3 minutes.
4. **First production render** — open
`https://worldbuilder.iamworkin.lan/studio/c32e0000-0000-4000-8000-000000000004`
and confirm the Cyberpunk Blue Jay demo prompt loads with five seeded fake
generated images. This Sprint 32 visitor-safe profile uses
`ClientMode=fake`; switch the image-generation env vars back to ComfyUI only
for an operator-owned GPU render lane.
4. **First production render** — verify
`https://worldbuilder.iamworkin.lan/healthz`, open
`https://worldbuilder.iamworkin.lan/settings`, and confirm the image backend
reports ComfyUI before running an operator-owned render lane.
## Health probes
@@ -56,13 +55,8 @@ Source: `D:\git\FlowerCore\FlowerCore.WorldBuilder` (master)
## Image generation backend
Sprint 32 pins the Kubernetes profile to
`FlowerCore:WorldBuilder:ImageGeneration:ClientMode=fake` with
`BaseUrl=http://127.0.0.1:1`. That keeps the public/internal visitor demo
deterministic, avoids GPU exposure, and still exercises the studio/gallery
surface with persisted generated-image metadata.
The previous ComfyUI backend target was `http://10.0.56.20:8188` on
BLUEJAY-WS (R9700 / gfx1201 / ROCm 7.2.1). Re-enable it only in an
operator-owned follow-up that also verifies workstation reachability and image
import freshness.
The live internal profile now uses
`FlowerCore:WorldBuilder:ImageGeneration:ClientMode=comfyui` with
`BaseUrl=http://10.0.56.20:8188` on BLUEJAY-WS (R9700 / gfx1201 / ROCm 7.2).
Keep the public host pre-staging disabled unless the five safe-to-expose gates
are rechecked; the live GPU lane is operator-owned and internal-only.

View File

@@ -5,10 +5,10 @@
#
# Image build (BLUEJAY-WS):
# bash deploy/build.sh # in FlowerCore.WorldBuilder repo
# podman save localhost/fc-worldbuilder:v<TAG> -o /tmp/fc-worldbuilder-v<TAG>.tar
# for h in 10.0.56.11 10.0.56.12 10.0.56.13; do
# scp /tmp/fc-worldbuilder-v<TAG>.tar fcadmin@$h:/tmp/
# ssh fcadmin@$h "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-worldbuilder-v<TAG>.tar"
# podman save localhost/fc-worldbuilder:v<TAG> -o artifacts/deploy/fc-worldbuilder-v<TAG>.tar
# for h in 10.0.56.11 10.0.56.12; do
# scp artifacts/deploy/fc-worldbuilder-v<TAG>.tar fcadmin@$h:/home/fcadmin/.fcv/
# ssh fcadmin@$h "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /home/fcadmin/.fcv/fc-worldbuilder-v<TAG>.tar"
# done
---
apiVersion: v1
@@ -77,6 +77,8 @@ spec:
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics/prometheus"
@@ -88,11 +90,12 @@ spec:
containers:
- name: web
# Bump tag for each rebuild. Initial deploy: v202605062048
image: localhost/fc-worldbuilder:v202605062048
image: localhost/fc-worldbuilder:v20260613-e4-about-edd6efc
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
@@ -114,14 +117,16 @@ spec:
value: "/data/gallery"
- name: FlowerCore__WorldBuilder__Export__RootPath
value: "/data/exports"
# Visitor-safe Sprint 32 profile: fake backend keeps public demo
# rendering deterministic and avoids exposing BLUEJAY-WS GPU.
# Operator-approved live GPU lane. Internal-only host targets
# BLUEJAY-WS ComfyUI; keep public host pre-staging disabled below.
- name: FlowerCore__WorldBuilder__ImageGeneration__BaseUrl
value: "http://127.0.0.1:1"
value: "http://10.0.56.20:8188"
- name: FlowerCore__WorldBuilder__ImageGeneration__ClientMode
value: "fake"
value: "comfyui"
- name: FlowerCore__WorldBuilder__ImageGeneration__BackendId
value: "fake"
value: "comfyui"
- name: FlowerCore__WorldBuilder__ImageGeneration__VisitorSafe
value: "false"
resources:
# Cluster CPU-request budget runs hot (99% on all 3 nodes at deploy
# time) while actual CPU usage is well below capacity. Idle Blazor
@@ -254,3 +259,26 @@ spec:
port: 80
tls:
secretName: worldbuilder-web-tls
# ---- PUBLIC HOST PRE-STAGING (DISABLED - Sprint 61+ exposure go-decision only) ----
# When the operator decides to expose worldbuilder-web publicly, uncomment + update the host,
# then verify the five safe-to-expose gates (authentik-safe-to-expose-readiness-2026-06-07.md section 2).
#
# --- IngressRoute ---
# apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: worldbuilder-web-public
# namespace: worldbuilder
# spec:
# entryPoints: [websecure]
# routes:
# - match: Host(`worldbuilder.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
# kind: Rule
# middlewares:
# - name: worldbuilder-web-public-profile-header # injects entitlement profile
# services:
# - name: worldbuilder-web
# port: 80
# tls: {}
# # POST/PUT/PATCH/DELETE miss every route -> Traefik 404 -> no admin writes on the public surface.
# # Reference pattern: dist.flowercore.io (already live + method-gated; do not edit that one).

View File

@@ -0,0 +1,83 @@
# GX10 Tenant Landing-Site Migration — Cutover Runbook
Date: 2026-06-16. Migrates the 5 per-tenant public landing sites from the OLD RKE2
cluster (`10.0.56.200` Traefik) to the GX10 ARM64 cluster (`10.0.57.202` VIP /
NodePort `10.0.56.14:32491`).
## Deployed on GX10 (DONE — staged-verified, NOT yet receiving public traffic)
| Domain(s) | GX10 ns | Workload | TLS secret (in ns + traefik-system) | Live content replicated |
|-----------------------------------|--------------------|---------------|-------------------------------------|-------------------------|
| bluejay.dev, www.bluejay.dev | `fc-tenant-andrew` | nginx:alpine | `cf-origin-bluejay-dev` | "Blue Jay" (custom) |
| timeforta.co, www.timeforta.co | `fc-tenant-dustin` | nginx:alpine | `cf-origin-timeforta-co` | "Coming Soon" (generic) |
| erckak.dev, www.erckak.dev | `fc-tenant-erik` | nginx:alpine | `cf-origin-erckak-dev` | "Erckak" (custom) |
| flowerinsider.xyz, www.* | `fc-tenant-fit` | nginx:alpine | `cf-origin-flowerinsider-xyz` | "Flower Insider" (custom)|
| matt.flowercore.io | `fc-tenant-matt` | nginx:alpine | `cf-origin-flowercore-io` | "Coming Soon" (generic) |
All nginx pods 1/1 Running, IngressRoutes priority 100 (override the GX10
`public-catchall`). Each site replicates EXACTLY what was live on OLD at migration
time, so cutover is content-invisible.
Staged verification (all HTTP 200, correct content, SNI-correct cert):
```
curl -sk --resolve <host>:32491:10.0.56.14 https://<host>:32491/
```
## Public routing reality (why NO automatic cutover happened)
Every tenant domain enters the network through Cloudflare (proxied) → a dedicated
pfSense WAN IP in 74.40.140.16/28 → pfSense port-forward. ALL FIVE currently forward
to OLD Traefik `10.0.56.200:443`:
| Domain | CF origin WAN IP | pfSense rdr today |
|-------------------|------------------|--------------------|
| bluejay.dev | 74.40.140.17 | → 10.0.56.200:443 |
| matt.flowercore.io| 74.40.140.19 | → 10.0.56.200:443 |
| timeforta.co | 74.40.140.21 | → 10.0.56.200:443 |
| erckak.dev | 74.40.140.23 | → 10.0.56.200:443 |
| flowerinsider.xyz | 74.40.140.25 | → 10.0.56.200:443 |
(Contrast: main flowercore.io = WAN `.24` → already GX10 `10.0.56.14:32491`.)
NOTE: matt.flowercore.io is bound to WAN `.19` (the MATT VPN IP), NOT `.24`, so the
"*.flowercore.io already NATs to GX10" assumption does NOT cover matt.
Because none of these NAT to GX10 yet, no cutover was performed (live sites untouched).
## OPERATOR ACTION — cutover = repoint the pfSense port-forward target
For each domain, change the HTTPS (and HTTP) port-forward TARGET from
`10.0.56.200` to `10.0.56.14:32491` (HTTPS) / `10.0.56.14:30776` (HTTP). pfSense
port-forwards (Firewall → NAT → Port Forward), edit these rule descriptions:
- `ANDREW: HTTPS to Traefik` 74.40.140.17:443 → change target `10.0.56.200:443` to `10.0.56.14:32491`
- `MATT: HTTPS to Traefik` 74.40.140.19:443 → change target `10.0.56.200:443` to `10.0.56.14:32491`
- `DUSTIN: HTTPS to Traefik` 74.40.140.21:443 → change target `10.0.56.200:443` to `10.0.56.14:32491`
- `ERIK: HTTPS to Traefik` 74.40.140.23:443 → change target `10.0.56.200:443` to `10.0.56.14:32491`
- `FIT: HTTPS to Traefik` 74.40.140.25:443 → change target `10.0.56.200:443` to `10.0.56.14:32491`
- (corresponding `:80 → 10.0.56.14:30776` HTTP rules likewise, optional — sites are HTTPS-only)
No Cloudflare DNS change is required: the WAN IPs stay the same, only the internal
NAT target moves. Each can be flipped independently (per-tenant blast radius).
Post-flip verify (external):
```
curl -sI https://<host>/ # expect HTTP 200, Server: cloudflare, unchanged content
```
## Rollback
OLD cluster left fully intact (ArgoCD apps infra-andrew/dustin/erik/fit Synced+Healthy,
pods Running). To roll back any domain: revert that pfSense port-forward target to
`10.0.56.200`.
## Notes
- The OLD cluster has DUPLICATE namespaces per tenant (`tenant-X` custom page +
`fc-tenant-X` generic landing), both with IngressRoutes claiming the same host.
Traefik non-deterministically picked a winner; live content was: andrew/erik/fit =
custom (`tenant-X`), dustin/matt = generic (`fc-tenant-X`). GX10 consolidates to ONE
namespace per tenant (`fc-tenant-X`) serving the content that was actually live.
- `infra-worldbuilder` (worldbuilder.iamworkin.lan, internal .NET app) was ALREADY
migrated to GX10 (`fc-worldbuilder`, 1/1 Running) — no action.
- `infra-flowercore` (tenant-flowercore/flowercore-web demo) has NO public route and is
superseded by the production `fc-system/fc-landing-public` (flowercore.io root) already
live on GX10 — intentionally NOT migrated.

View File

@@ -0,0 +1,225 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-tenant-andrew
labels:
app.kubernetes.io/part-of: bluejay-infra
flowercore.io/tenant: andrew
---
apiVersion: v1
kind: ConfigMap
metadata:
name: andrew-web-html
namespace: fc-tenant-andrew
data:
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Blue Jay — bluejay.dev</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0A1628;
color: #e0e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bg {
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(43,138,255,0.15) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 100%, rgba(43,138,255,0.08) 0%, transparent 60%),
linear-gradient(135deg, #0A1628 0%, #111E36 50%, #0A1628 100%);
z-index: 0;
}
.container {
position: relative;
z-index: 1;
text-align: center;
padding: 2rem;
max-width: 640px;
width: 100%;
}
.badge {
display: inline-block;
background: rgba(43,138,255,0.12);
border: 1px solid rgba(43,138,255,0.25);
border-radius: 20px;
padding: 0.4rem 1.2rem;
font-size: 0.8rem;
color: #2B8AFF;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 2rem;
}
.icon {
font-size: 4rem;
margin-bottom: 1.5rem;
filter: drop-shadow(0 0 30px rgba(43,138,255,0.3));
}
h1 {
font-size: 2.8rem;
font-weight: 700;
background: linear-gradient(135deg, #2B8AFF 0%, #6BB3FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.domain {
font-size: 1.1rem;
color: #6BB3FF;
font-weight: 300;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
margin-bottom: 2.5rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(43,138,255,0.08);
border: 1px solid rgba(43,138,255,0.15);
border-radius: 8px;
padding: 1rem 2rem;
margin-bottom: 2rem;
}
.status .dot {
width: 8px;
height: 8px;
background: #2B8AFF;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; box-shadow: 0 0 8px rgba(43,138,255,0.6); }
}
.status p {
font-size: 0.95rem;
color: #8aa8c4;
}
.divider {
width: 40px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(43,138,255,0.4), transparent);
margin: 2rem auto;
}
.footer {
color: #3a5570;
font-size: 0.75rem;
line-height: 1.6;
}
.footer a {
color: #4a7a9e;
text-decoration: none;
}
@media (max-width: 480px) {
h1 { font-size: 2rem; }
.icon { font-size: 3rem; }
.container { padding: 1.5rem; }
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="container">
<div class="badge">Andrew's Space</div>
<div class="icon">&#x1F426;</div>
<h1>Blue Jay</h1>
<p class="domain">bluejay.dev</p>
<div class="status">
<span class="dot"></span>
<p>Coming Soon</p>
</div>
<div class="divider"></div>
<p class="footer">
Powered by <a href="https://flowercore.io">FlowerCore</a>
</p>
</div>
</body>
</html>
---
apiVersion: v1
kind: ConfigMap
metadata:
name: andrew-web-nginx-conf
namespace: fc-tenant-andrew
data:
default.conf: |
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ =404; }
location /healthz { access_log off; return 200 "ok"; add_header Content-Type text/plain; }
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: andrew-web
namespace: fc-tenant-andrew
labels:
app: andrew-web
spec:
replicas: 1
selector:
matchLabels:
app: andrew-web
template:
metadata:
labels:
app: andrew-web
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
- name: html
mountPath: /usr/share/nginx/html
resources:
requests: { memory: 32Mi, cpu: 10m }
limits: { memory: 64Mi, cpu: 50m }
livenessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: nginx-conf
configMap: { name: andrew-web-nginx-conf }
- name: html
configMap: { name: andrew-web-html }
---
apiVersion: v1
kind: Service
metadata:
name: andrew-web
namespace: fc-tenant-andrew
spec:
selector:
app: andrew-web
ports:
- port: 80
targetPort: 80
name: http

View File

@@ -0,0 +1,18 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: andrew-web
namespace: fc-tenant-andrew
spec:
entryPoints:
- websecure
routes:
- match: Host(`bluejay.dev`) || Host(`www.bluejay.dev`)
kind: Rule
priority: 100
services:
- name: andrew-web
port: 80
tls:
secretName: cf-origin-bluejay-dev

View File

@@ -0,0 +1,208 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-tenant-dustin
labels:
app.kubernetes.io/part-of: bluejay-infra
flowercore.io/tenant: dustin
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dustin-web-html
namespace: fc-tenant-dustin
data:
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>timeforta.co &mdash; Coming Soon</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
padding: 2rem;
max-width: 600px;
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
filter: drop-shadow(0 0 20px #4aff9e40);
}
h1 {
font-size: 2.5rem;
font-weight: 700;
color: #4aff9e;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.domain {
font-size: 1.1rem;
color: #888;
margin-bottom: 2rem;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
.divider {
width: 60px;
height: 3px;
background: #4aff9e;
margin: 0 auto 2rem;
border-radius: 2px;
}
.tagline {
font-size: 1.3rem;
color: #ccc;
margin-bottom: 1rem;
line-height: 1.5;
}
.status {
display: inline-block;
padding: 0.4rem 1.2rem;
background: #4aff9e15;
border: 1px solid #4aff9e40;
border-radius: 20px;
color: #4aff9e;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-top: 1.5rem;
}
.pulse {
display: inline-block;
width: 8px;
height: 8px;
background: #4aff9e;
border-radius: 50%;
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
footer {
position: fixed;
bottom: 0;
width: 100%;
text-align: center;
padding: 1.5rem;
color: #555;
font-size: 0.8rem;
border-top: 1px solid #ffffff08;
background: #1a1a2e;
}
footer a {
color: #4a9eff;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">&#x1F32E;</div>
<h1>timeforta.co</h1>
<div class="domain">Dustin</div>
<div class="divider"></div>
<p class="tagline">It's always time for tacos.</p>
<div class="status">
<span class="pulse"></span>
Under Construction
</div>
</div>
<footer>
Powered by <a href="https://flowercore.io">FlowerCore</a> &bull; Hosted on Blue Jay Infrastructure
</footer>
</body>
</html>
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dustin-web-nginx-conf
namespace: fc-tenant-dustin
data:
default.conf: |
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ =404; }
location /healthz { access_log off; return 200 "ok"; add_header Content-Type text/plain; }
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dustin-web
namespace: fc-tenant-dustin
labels:
app: dustin-web
spec:
replicas: 1
selector:
matchLabels:
app: dustin-web
template:
metadata:
labels:
app: dustin-web
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
- name: html
mountPath: /usr/share/nginx/html
resources:
requests: { memory: 32Mi, cpu: 10m }
limits: { memory: 64Mi, cpu: 50m }
livenessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: nginx-conf
configMap: { name: dustin-web-nginx-conf }
- name: html
configMap: { name: dustin-web-html }
---
apiVersion: v1
kind: Service
metadata:
name: dustin-web
namespace: fc-tenant-dustin
spec:
selector:
app: dustin-web
ports:
- port: 80
targetPort: 80
name: http

View File

@@ -0,0 +1,18 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: dustin-web
namespace: fc-tenant-dustin
spec:
entryPoints:
- websecure
routes:
- match: Host(`timeforta.co`) || Host(`www.timeforta.co`)
kind: Rule
priority: 100
services:
- name: dustin-web
port: 80
tls:
secretName: cf-origin-timeforta-co

View File

@@ -0,0 +1,225 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-tenant-erik
labels:
app.kubernetes.io/part-of: bluejay-infra
flowercore.io/tenant: erik
---
apiVersion: v1
kind: ConfigMap
metadata:
name: erik-web-html
namespace: fc-tenant-erik
data:
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Erckak — erckak.dev</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0A1628;
color: #e0e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bg {
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(43,138,255,0.15) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 100%, rgba(43,138,255,0.08) 0%, transparent 60%),
linear-gradient(135deg, #0A1628 0%, #111E36 50%, #0A1628 100%);
z-index: 0;
}
.container {
position: relative;
z-index: 1;
text-align: center;
padding: 2rem;
max-width: 640px;
width: 100%;
}
.badge {
display: inline-block;
background: rgba(43,138,255,0.12);
border: 1px solid rgba(43,138,255,0.25);
border-radius: 20px;
padding: 0.4rem 1.2rem;
font-size: 0.8rem;
color: #2B8AFF;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 2rem;
}
.icon {
font-size: 4rem;
margin-bottom: 1.5rem;
filter: drop-shadow(0 0 30px rgba(43,138,255,0.3));
}
h1 {
font-size: 2.8rem;
font-weight: 700;
background: linear-gradient(135deg, #2B8AFF 0%, #6BB3FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.domain {
font-size: 1.1rem;
color: #6BB3FF;
font-weight: 300;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
margin-bottom: 2.5rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(43,138,255,0.08);
border: 1px solid rgba(43,138,255,0.15);
border-radius: 8px;
padding: 1rem 2rem;
margin-bottom: 2rem;
}
.status .dot {
width: 8px;
height: 8px;
background: #2B8AFF;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; box-shadow: 0 0 8px rgba(43,138,255,0.6); }
}
.status p {
font-size: 0.95rem;
color: #8aa8c4;
}
.divider {
width: 40px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(43,138,255,0.4), transparent);
margin: 2rem auto;
}
.footer {
color: #3a5570;
font-size: 0.75rem;
line-height: 1.6;
}
.footer a {
color: #4a7a9e;
text-decoration: none;
}
@media (max-width: 480px) {
h1 { font-size: 2rem; }
.icon { font-size: 3rem; }
.container { padding: 1.5rem; }
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="container">
<div class="badge">Erik's Space</div>
<div class="icon">&#x1F680;</div>
<h1>Erckak</h1>
<p class="domain">erckak.dev</p>
<div class="status">
<span class="dot"></span>
<p>Coming Soon</p>
</div>
<div class="divider"></div>
<p class="footer">
Powered by <a href="https://flowercore.io">FlowerCore</a>
</p>
</div>
</body>
</html>
---
apiVersion: v1
kind: ConfigMap
metadata:
name: erik-web-nginx-conf
namespace: fc-tenant-erik
data:
default.conf: |
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ =404; }
location /healthz { access_log off; return 200 "ok"; add_header Content-Type text/plain; }
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: erik-web
namespace: fc-tenant-erik
labels:
app: erik-web
spec:
replicas: 1
selector:
matchLabels:
app: erik-web
template:
metadata:
labels:
app: erik-web
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
- name: html
mountPath: /usr/share/nginx/html
resources:
requests: { memory: 32Mi, cpu: 10m }
limits: { memory: 64Mi, cpu: 50m }
livenessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: nginx-conf
configMap: { name: erik-web-nginx-conf }
- name: html
configMap: { name: erik-web-html }
---
apiVersion: v1
kind: Service
metadata:
name: erik-web
namespace: fc-tenant-erik
spec:
selector:
app: erik-web
ports:
- port: 80
targetPort: 80
name: http

View File

@@ -0,0 +1,18 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: erik-web
namespace: fc-tenant-erik
spec:
entryPoints:
- websecure
routes:
- match: Host(`erckak.dev`) || Host(`www.erckak.dev`)
kind: Rule
priority: 100
services:
- name: erik-web
port: 80
tls:
secretName: cf-origin-erckak-dev

View File

@@ -0,0 +1,225 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-tenant-fit
labels:
app.kubernetes.io/part-of: bluejay-infra
flowercore.io/tenant: fit
---
apiVersion: v1
kind: ConfigMap
metadata:
name: fit-web-html
namespace: fc-tenant-fit
data:
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flower Insider — flowerinsider.xyz</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0A1628;
color: #e0e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bg {
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(43,138,255,0.15) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 100%, rgba(43,138,255,0.08) 0%, transparent 60%),
linear-gradient(135deg, #0A1628 0%, #111E36 50%, #0A1628 100%);
z-index: 0;
}
.container {
position: relative;
z-index: 1;
text-align: center;
padding: 2rem;
max-width: 640px;
width: 100%;
}
.badge {
display: inline-block;
background: rgba(43,138,255,0.12);
border: 1px solid rgba(43,138,255,0.25);
border-radius: 20px;
padding: 0.4rem 1.2rem;
font-size: 0.8rem;
color: #2B8AFF;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 2rem;
}
.icon {
font-size: 4rem;
margin-bottom: 1.5rem;
filter: drop-shadow(0 0 30px rgba(43,138,255,0.3));
}
h1 {
font-size: 2.8rem;
font-weight: 700;
background: linear-gradient(135deg, #2B8AFF 0%, #6BB3FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.domain {
font-size: 1.1rem;
color: #6BB3FF;
font-weight: 300;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
margin-bottom: 2.5rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(43,138,255,0.08);
border: 1px solid rgba(43,138,255,0.15);
border-radius: 8px;
padding: 1rem 2rem;
margin-bottom: 2rem;
}
.status .dot {
width: 8px;
height: 8px;
background: #2B8AFF;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; box-shadow: 0 0 8px rgba(43,138,255,0.6); }
}
.status p {
font-size: 0.95rem;
color: #8aa8c4;
}
.divider {
width: 40px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(43,138,255,0.4), transparent);
margin: 2rem auto;
}
.footer {
color: #3a5570;
font-size: 0.75rem;
line-height: 1.6;
}
.footer a {
color: #4a7a9e;
text-decoration: none;
}
@media (max-width: 480px) {
h1 { font-size: 2rem; }
.icon { font-size: 3rem; }
.container { padding: 1.5rem; }
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="container">
<div class="badge">Flower Insider Team</div>
<div class="icon">&#x1F338;</div>
<h1>Flower Insider</h1>
<p class="domain">flowerinsider.xyz</p>
<div class="status">
<span class="dot"></span>
<p>Coming Soon</p>
</div>
<div class="divider"></div>
<p class="footer">
Powered by <a href="https://flowercore.io">FlowerCore</a>
</p>
</div>
</body>
</html>
---
apiVersion: v1
kind: ConfigMap
metadata:
name: fit-web-nginx-conf
namespace: fc-tenant-fit
data:
default.conf: |
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ =404; }
location /healthz { access_log off; return 200 "ok"; add_header Content-Type text/plain; }
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fit-web
namespace: fc-tenant-fit
labels:
app: fit-web
spec:
replicas: 1
selector:
matchLabels:
app: fit-web
template:
metadata:
labels:
app: fit-web
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
- name: html
mountPath: /usr/share/nginx/html
resources:
requests: { memory: 32Mi, cpu: 10m }
limits: { memory: 64Mi, cpu: 50m }
livenessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: nginx-conf
configMap: { name: fit-web-nginx-conf }
- name: html
configMap: { name: fit-web-html }
---
apiVersion: v1
kind: Service
metadata:
name: fit-web
namespace: fc-tenant-fit
spec:
selector:
app: fit-web
ports:
- port: 80
targetPort: 80
name: http

View File

@@ -0,0 +1,18 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fit-web
namespace: fc-tenant-fit
spec:
entryPoints:
- websecure
routes:
- match: Host(`flowerinsider.xyz`) || Host(`www.flowerinsider.xyz`)
kind: Rule
priority: 100
services:
- name: fit-web
port: 80
tls:
secretName: cf-origin-flowerinsider-xyz

View File

@@ -0,0 +1,208 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-tenant-matt
labels:
app.kubernetes.io/part-of: bluejay-infra
flowercore.io/tenant: matt
---
apiVersion: v1
kind: ConfigMap
metadata:
name: matt-web-html
namespace: fc-tenant-matt
data:
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>matt.flowercore.io &mdash; Coming Soon</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
padding: 2rem;
max-width: 600px;
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
filter: drop-shadow(0 0 20px #ff6b4a40);
}
h1 {
font-size: 2.5rem;
font-weight: 700;
color: #ff6b4a;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.domain {
font-size: 1.1rem;
color: #888;
margin-bottom: 2rem;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
.divider {
width: 60px;
height: 3px;
background: #ff6b4a;
margin: 0 auto 2rem;
border-radius: 2px;
}
.tagline {
font-size: 1.3rem;
color: #ccc;
margin-bottom: 1rem;
line-height: 1.5;
}
.status {
display: inline-block;
padding: 0.4rem 1.2rem;
background: #ff6b4a15;
border: 1px solid #ff6b4a40;
border-radius: 20px;
color: #ff6b4a;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-top: 1.5rem;
}
.pulse {
display: inline-block;
width: 8px;
height: 8px;
background: #ff6b4a;
border-radius: 50%;
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
footer {
position: fixed;
bottom: 0;
width: 100%;
text-align: center;
padding: 1.5rem;
color: #555;
font-size: 0.8rem;
border-top: 1px solid #ffffff08;
background: #1a1a2e;
}
footer a {
color: #4a9eff;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">&#x1F525;</div>
<h1>matt.flowercore.io</h1>
<div class="domain">Matt</div>
<div class="divider"></div>
<p class="tagline">Building something extraordinary.</p>
<div class="status">
<span class="pulse"></span>
Under Construction
</div>
</div>
<footer>
Powered by <a href="https://flowercore.io">FlowerCore</a> &bull; Hosted on Blue Jay Infrastructure
</footer>
</body>
</html>
---
apiVersion: v1
kind: ConfigMap
metadata:
name: matt-web-nginx-conf
namespace: fc-tenant-matt
data:
default.conf: |
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ =404; }
location /healthz { access_log off; return 200 "ok"; add_header Content-Type text/plain; }
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: matt-web
namespace: fc-tenant-matt
labels:
app: matt-web
spec:
replicas: 1
selector:
matchLabels:
app: matt-web
template:
metadata:
labels:
app: matt-web
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
- name: html
mountPath: /usr/share/nginx/html
resources:
requests: { memory: 32Mi, cpu: 10m }
limits: { memory: 64Mi, cpu: 50m }
livenessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet: { path: /healthz, port: 80 }
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: nginx-conf
configMap: { name: matt-web-nginx-conf }
- name: html
configMap: { name: matt-web-html }
---
apiVersion: v1
kind: Service
metadata:
name: matt-web
namespace: fc-tenant-matt
spec:
selector:
app: matt-web
ports:
- port: 80
targetPort: 80
name: http

View File

@@ -0,0 +1,18 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: matt-web
namespace: fc-tenant-matt
spec:
entryPoints:
- websecure
routes:
- match: Host(`matt.flowercore.io`)
kind: Rule
priority: 100
services:
- name: matt-web
port: 80
tls:
secretName: cf-origin-flowercore-io

View File

@@ -0,0 +1,129 @@
# authentik-tenant-mapping-sync — GATED manifest staging
**Status:** GATED (suspended). **ADR:** ADR-198 §2.A P1 (Au-1 / Au-3 substrate). **Pairs:** Codex **Cx2-7**.
This directory is a **Notes staging area**, NOT a deploy target. The orchestrator relocates
`cronjob.yaml` into a `gated/` path **outside** `bluejay-infra/apps/` so ArgoCD's `apps/*`
directory generator never picks it up. Nothing here runs until the activation steps below.
## What this is
A nightly Kubernetes `CronJob` that runs
[`scripts/authentik/authentik-tenant-mapping-sync.py`](../../../scripts/authentik/authentik-tenant-mapping-sync.py)
(Notes repo). The script:
- reads the 1Password Document **`flowercore-tenant-mapping`** (vault `IAmWorkin`, field
`mapping`) via **1Password Connect REST** — never the 1Password CLI/desktop (operator hard rule);
- parses + light-validates the mapping JSON (schema: [`authentik-oidc-tenant-mapping-schema.md`](../../standards/authentik-oidc-tenant-mapping-schema.md) — `version==1`, `mappings[]` with `authentikGroup` / `fcTenantId` / `fcRole`);
- reconciles each distinct `authentikGroup` into Authentik `/api/v3/core/groups/`:
create-if-missing, PATCH-managed-markers-on-drift, **never delete or disable unmanaged groups**;
- emits structured (Serilog-shaped JSON) logs and exits 0 on success.
It is the **slow nightly fix-up path**. The **<1s hot path** stays the MCP tool
`authentik_sync_tenant_mapping` (schema doc §6.2 force-broadcast). This CronJob does NOT
broadcast SignalR — group reconcile is its only side effect; services pick up mapping changes
on their own 5-minute 1P refresh.
## Why it is GATED (two locks)
1. **`spec.suspend: true`** in `cronjob.yaml` — belt-and-suspenders so even if applied it never fires.
2. **Lives outside `apps/`** — staged here in Notes; ArgoCD does not manage it.
Both must be cleared to go live. This pairs Codex **Cx2-7**: do not activate ahead of the Au-3
public-go for tenant self-registration.
## Files
| File | Purpose |
|------|---------|
| `cronjob.yaml` | The suspended `CronJob` + the script-delivery `ConfigMap` (placeholder body). |
| `README.md` | This file. |
| `scripts/authentik/authentik-tenant-mapping-sync.py` | The reconcile script (canonical source; NOT in this dir). |
## Secrets (referenced, not invented)
No secret **values** appear in `cronjob.yaml` — only `secretKeyRef`s:
- **`AUTHENTIK_TOKEN`** ← `Secret authentik/authentik-credentials` key `BOOTSTRAP_ADMIN_TOKEN`
(already exists; the same token `provision-oidc-client.py` reads). **Au-9 caveat:** this is the
never-rotated bootstrap token — when `/rotate-password rotate authentik` (Au-9) lands, this
CronJob is one of its fan-out consumers.
- **`OP_TOKEN`** ← `Secret authentik/tenant-mapping-sync-op-token` key `token`.
### OP_TOKEN cross-namespace
The canonical 1P Connect token Secret is `onepassword-system/onepassword-token`, but this
CronJob runs in the `authentik` namespace and K8s Secrets are namespace-scoped. Pick one at
activation:
- **Option A (copy, simplest).** Mint a same-namespace copy right before un-suspending:
```sh
kubectl get secret onepassword-token -n onepassword-system -o jsonpath='{.data.token}' \
| base64 -d \
| kubectl create secret generic tenant-mapping-sync-op-token -n authentik \
--from-file=token=/dev/stdin --dry-run=client -o yaml | kubectl apply -f -
```
(Re-run whenever the Connect token rotates — add this CronJob to the **Au-10** Connect-token
fan-out checklist so the copy can't go stale.)
- **Option B (CRD, preferred long-term).** Use an `OnePasswordItem` CRD
(`feedback_1password_operator_pattern`) so the 1P operator mints/refreshes
`authentik/tenant-mapping-sync-op-token` automatically — no manual copy, rotation-safe.
> If neither secret exists yet, that's fine **while suspended** — the job never schedules.
## How to ACTIVATE (at Au-3 public-go)
1. **Pre-flight (workstation dry-run, writes nothing):**
```sh
export AUTHENTIK_TOKEN=... # or let it read authentik/authentik-credentials via kubectl
export OP_TOKEN=... # or rely on credential-helper.sh get_op_token (fcadmin@noc1)
python scripts/authentik/authentik-tenant-mapping-sync.py --dry-run --verbose
```
Confirm the planned create/update set matches the 1P mapping document.
2. **Provide `OP_TOKEN` in-cluster** — Option A or B above.
3. **Materialize the script ConfigMap from the canonical file** (do NOT hand-edit a copy into
`cronjob.yaml` — the embedded body is a deliberate placeholder):
```sh
kubectl create configmap authentik-tenant-mapping-sync-script -n authentik \
--from-file=authentik-tenant-mapping-sync.py=scripts/authentik/authentik-tenant-mapping-sync.py \
--dry-run=client -o yaml | kubectl apply -f -
```
(Or, in the imaged future per ADR-198 §2.B P3, bake the script into `fc-runtime-base` and
drop the ConfigMap volume.)
4. **Relocate into bluejay-infra** — move `cronjob.yaml` into a `gated/` (or `apps/`) path in
`bluejay-infra` per the orchestrator's placement decision. If under `apps/`, ArgoCD will sync it.
5. **Un-suspend** — set `spec.suspend: false` (commit in `bluejay-infra` so ArgoCD selfHeal
doesn't revert), or one-off:
```sh
kubectl patch cronjob authentik-tenant-mapping-sync -n authentik \
-p '{"spec":{"suspend":false}}'
```
6. **Smoke (VG-A1):** trigger an immediate run and check the structured logs:
```sh
kubectl create job --from=cronjob/authentik-tenant-mapping-sync tms-smoke -n authentik
kubectl logs -n authentik job/tms-smoke
```
Then edit a mapping entry in 1P and confirm the next run reconciles the group; the <1s
propagation still comes from the MCP `authentik_sync_tenant_mapping` force-broadcast.
## Rollback
Re-suspend (`spec.suspend: true`) or delete the CronJob. The script never deletes Authentik
groups, so a bad run can only over-create groups present in the mapping — remove any unwanted
group by hand in the Authentik admin UI. No data loss path.
## Idempotency / safety summary
- Re-running is a no-op when groups already match (mirrors `provision-oidc-client.py`).
- Only the managed attribute block (`fc:managed-by` / `fc:tenant` / `fc:role` / optional
`fc:label` / `fc:regulated` / `fc:strict-mode`) is asserted; group parent/users/roles are
never touched.
- Wildcard SuperAdmin entries (`fcTenantId: "*"`) do not create a per-tenant group.
- `--dry-run` prints the plan and writes nothing — always run it first.
## Cross-links
- [`docs/standards/auth-acl-unattended-lifecycle-plan.md`](../../standards/auth-acl-unattended-lifecycle-plan.md) — ADR-198; Au-1/Au-3 lanes, VG-A1/A2.
- [`docs/standards/authentik-oidc-tenant-mapping-schema.md`](../../standards/authentik-oidc-tenant-mapping-schema.md) — the mapping JSON shape + 1P item layout (§2/§3).
- [`scripts/authentik/provision-oidc-client.py`](../../../scripts/authentik/provision-oidc-client.py) — sibling idempotent provisioner (same API + posture).
- [`scripts/credential-helper.sh`](../../../scripts/credential-helper.sh) — `get_op_token` 1P Connect bootstrap (fcadmin@noc1).

View File

@@ -0,0 +1,151 @@
# =====================================================================================
# authentik-tenant-mapping-sync — GATED nightly CronJob (Au-3 / ADR-198 §2.A P1)
#
# STATUS: GATED. spec.suspend: true (belt-and-suspenders). This manifest lives in a Notes
# STAGING path (docs/gated-manifests/) and is NOT under bluejay-infra apps/, so ArgoCD
# does not deploy it. It does NOTHING until Au-3 public-go (see README.md in this dir).
#
# WHAT IT RUNS: scripts/authentik/authentik-tenant-mapping-sync.py (Notes repo) — reads the
# 1Password Document `flowercore-tenant-mapping` via Connect REST and reconciles its
# mappings[].authentikGroup entries into Authentik groups (idempotent; never deletes
# unmanaged groups). Pairs Codex Cx2-7.
#
# SECRETS (referenced, NOT invented — no secret VALUES in this file):
# AUTHENTIK_TOKEN <- Secret authentik/authentik-credentials key BOOTSTRAP_ADMIN_TOKEN (exists)
# OP_TOKEN <- Secret authentik/tenant-mapping-sync-op-token key token
# (a copy of onepassword-system/onepassword-token — see README "OP_TOKEN
# cross-namespace" for the one-liner that mints it; OR mint via the
# OnePasswordItem CRD per feedback_1password_operator_pattern).
#
# The script is delivered via the ConfigMap below (same pattern as guacamole guac-k8s-sync).
# When this lane is libraryized/imaged later (ADR-198 §2.B P3) this ConfigMap can be replaced
# by a baked image; for now ConfigMap-delivery keeps the script the single source of truth.
# =====================================================================================
apiVersion: batch/v1
kind: CronJob
metadata:
name: authentik-tenant-mapping-sync
namespace: authentik
labels:
app.kubernetes.io/name: authentik-tenant-mapping-sync
app.kubernetes.io/component: sync
app.kubernetes.io/part-of: flowercore-identity
flowercore.io/adr: "198"
flowercore.io/gated: "true"
annotations:
flowercore.io/gate: "Au-3 public-go — suspended until tenant self-registration goes live"
flowercore.io/pairs-with: "Codex Cx2-7"
spec:
# GATE: suspended so it never fires until an operator un-suspends at Au-3 public-go.
suspend: true
# Nightly at 03:17 (off-peak; jittered minute to avoid colliding with other 03:00 jobs).
schedule: "17 3 * * *"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 600
template:
metadata:
labels:
app.kubernetes.io/name: authentik-tenant-mapping-sync
app.kubernetes.io/component: sync
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: sync
# python:3.12-slim is sufficient: the script uses only the stdlib (urllib/json/ssl).
# No pip install needed. Pin a digest at activation time for air-gap reproducibility.
image: python:3.12-slim
imagePullPolicy: IfNotPresent
command:
- python3
- /scripts/authentik-tenant-mapping-sync.py
# NOTE: no --dry-run here -> this is the real reconcile. Operators wanting a
# dry-run first should `kubectl create job --from=cronjob/... ` with the arg
# appended, or run the script from a workstation. See README.
env:
- name: AUTHENTIK_URL
value: "https://id.iamworkin.lan"
- name: OP_CONNECT_URL
value: "http://10.0.56.10:8180/v1" # port 8180, NOT 8443
- name: OP_VAULT_ID
value: "qaphopopkryhbg353ukzhhuqoq" # IAmWorkin
- name: TENANT_MAPPING_ITEM
value: "flowercore-tenant-mapping"
- name: TENANT_MAPPING_FIELD
value: "mapping"
- name: AUTHENTIK_TOKEN
valueFrom:
secretKeyRef:
name: authentik-credentials
key: BOOTSTRAP_ADMIN_TOKEN
- name: OP_TOKEN
valueFrom:
secretKeyRef:
# A same-namespace copy of onepassword-system/onepassword-token.
# See README "OP_TOKEN cross-namespace". Until Au-3 this Secret need
# not exist (the job is suspended).
name: tenant-mapping-sync-op-token
key: token
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: script
mountPath: /scripts
readOnly: true
volumes:
- name: script
configMap:
name: authentik-tenant-mapping-sync-script
defaultMode: 0555
---
# The reconcile script, delivered as a ConfigMap (single source of truth = the Notes repo
# scripts/authentik/authentik-tenant-mapping-sync.py). At activation, regenerate this
# ConfigMap from the live script so the two never drift, e.g.:
# kubectl create configmap authentik-tenant-mapping-sync-script -n authentik \
# --from-file=authentik-tenant-mapping-sync.py=scripts/authentik/authentik-tenant-mapping-sync.py \
# --dry-run=client -o yaml > docs/gated-manifests/authentik-tenant-sync/configmap.script.yaml
# (kept as a placeholder body here so the manifest set is self-describing; the real body is
# the script file — DO NOT hand-edit a divergent copy into this ConfigMap.)
apiVersion: v1
kind: ConfigMap
metadata:
name: authentik-tenant-mapping-sync-script
namespace: authentik
labels:
app.kubernetes.io/name: authentik-tenant-mapping-sync
app.kubernetes.io/component: sync
flowercore.io/gated: "true"
annotations:
flowercore.io/source: "scripts/authentik/authentik-tenant-mapping-sync.py (Notes repo) — regenerate at activation, do not hand-edit"
data:
authentik-tenant-mapping-sync.py: |
# PLACEHOLDER — regenerate from the canonical script at activation (see annotation above).
# The Notes repo file scripts/authentik/authentik-tenant-mapping-sync.py is the source of
# truth; embedding a hand-copy here would drift. The orchestrator (or the activation
# runbook) materializes this ConfigMap from the live script via `kubectl create configmap
# ... --from-file=...` before un-suspending the CronJob.
import sys
sys.exit("authentik-tenant-mapping-sync ConfigMap not materialized from the canonical "
"script — regenerate with kubectl create configmap --from-file before activation.")

View File

@@ -0,0 +1,39 @@
# Public-TLS substrate (gated)
**Lane:** Cl-infra-2 (deep-regroup 2026-06-13). **Status:** authored, **NOT applied** — operator-gated.
This directory holds the Let's Encrypt + isolation substrate for **public** multi-tenant
web hosting. It lives **outside `apps/`** on purpose: the bluejay-infra ApplicationSet only
reconciles `apps/*`, so nothing here is auto-applied. Applying a cert-manager ACME
`ClusterIssuer` registers an ACME account immediately, so these stay inert until the
operator opens the web-hosting public-exposure gate (**R-1**).
## What's here
| File | What | Activate when |
|---|---|---|
| `letsencrypt-issuers.yaml` | `letsencrypt-staging` + `letsencrypt-prod` ClusterIssuers (HTTP-01 via Traefik; DNS-01 stub for wildcards) | Public-go. Move to `apps/cluster-issuers/`, **staging first**. |
| `tenant-networkpolicy-template.yaml` | Per-tenant default-deny + allowlist NetworkPolicy (Traefik ingress, CoreDNS, own-DB egress only) | Rendered per tenant at provision time (Wh-C2 isolation). |
## The gate
Public exposure is **NO-GO** until the §6 go/no-go checklist in
[`docs/standards/web-hosting-production-readiness-plan.md`](../../../FlowerCore.Notes/docs/standards/web-hosting-production-readiness-plan.md)
is green (currently 14/14 red) **and** the operator explicitly opens R-1. Internal
`*.iamworkin.lan` TLS stays on **step-ca** (`apps/fc-dns/fc-dns.yaml``step-ca-dns01`);
these LE issuers are **only** for public tenant domains.
## Pairing
- **Codex Wh-C1** consumes `letsencrypt-staging`/`-prod` for hybrid public TLS on
FlowerCore.PHP/MySQL/DNS.
- **Codex Wh-C2** consumes the NetworkPolicy template for cross-tenant isolation suites.
## Activation checklist (public-go)
1. Wire a public DNS-01 solver (Cloudflare/Namecheap webhook) **or** confirm public tenant
domains route HTTP-01 to the cluster ingress.
2. `git mv gated/public-tls/letsencrypt-issuers.yaml apps/cluster-issuers/` — staging only.
3. Issue one **staging** cert for a throwaway public domain; verify the chain in a browser.
4. Flip that tenant's Certificate `issuerRef` to `letsencrypt-prod`; mind LE rate limits.
5. Render `tenant-networkpolicy-template.yaml` per tenant; run the Wh-C2 negative suites.

View File

@@ -0,0 +1,78 @@
# ============================================================================
# Let's Encrypt ClusterIssuers — PUBLIC TLS substrate (Cl-infra-2, deep-regroup 2026-06-13)
# ============================================================================
# GATED. This file lives OUTSIDE apps/ on purpose, so the bluejay-infra
# ApplicationSet does NOT auto-apply it. Applying a cert-manager ACME
# ClusterIssuer registers an ACME account immediately, so we keep these inert
# until the operator opens the web-hosting public-exposure gate (R-1; the §6
# go/no-go checklist in docs/standards/web-hosting-production-readiness-plan.md
# is currently 14/14 red).
#
# Pairs with Codex Wh-C1 (FlowerCore.PHP/MySQL/DNS hybrid public TLS) and
# Wh-C2 (isolation). Internal *.iamworkin.lan certs STAY on step-ca
# (apps/fc-dns/fc-dns.yaml: ClusterIssuer step-ca-dns01) — these LE issuers are
# ONLY for public tenant domains.
#
# TO ACTIVATE (operator public-go):
# 1. Confirm a public DNS-01 solver is wired (Cloudflare/Namecheap webhook) OR
# that public tenant domains route HTTP-01 to the cluster's public ingress.
# 2. Move this file to apps/cluster-issuers/ (the ApplicationSet will create
# infra-cluster-issuers and apply it), staging FIRST.
# 3. Issue ONE staging cert for a throwaway public domain, verify the chain,
# THEN switch that tenant's Certificate issuerRef to letsencrypt-prod.
# 4. Mind LE prod rate limits (50 certs/registered-domain/week, 5 dupes/week).
#
# Registration email is for expiry notices only — adjust to a role address if
# desired (astoltz@iamwork.in is the current operator contact).
# ----------------------------------------------------------------------------
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
labels:
app.kubernetes.io/part-of: flowercore
flowercore.io/created-by: bluejay-infra
flowercore.io/gate: public-tls
spec:
acme:
# LE STAGING — untrusted certs, generous limits. Use this first, always.
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: astoltz@iamwork.in
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
# HTTP-01 via Traefik. Requires the public tenant domain's :80 traffic to
# reach the cluster ingress. For wildcard / apex without inbound :80, swap
# to the dns01 solver block below (needs a public DNS provider webhook).
- http01:
ingress:
class: traefik
# --- DNS-01 alternative for wildcards (uncomment + wire a public DNS webhook) ---
# - dns01:
# webhook:
# groupName: acme.flowercore.io # or the cloudflare/namecheap solver
# solverName: <public-dns-solver>
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
labels:
app.kubernetes.io/part-of: flowercore
flowercore.io/created-by: bluejay-infra
flowercore.io/gate: public-tls
spec:
acme:
# LE PRODUCTION — trusted certs, strict rate limits. Only after staging proves out.
server: https://acme-v02.api.letsencrypt.org/directory
email: astoltz@iamwork.in
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: traefik
# - dns01:
# webhook:
# groupName: acme.flowercore.io
# solverName: <public-dns-solver>

View File

@@ -0,0 +1,59 @@
# ============================================================================
# Per-tenant NetworkPolicy TEMPLATE — web-hosting isolation (Cl-infra-2 / Wh-C2)
# ============================================================================
# GATED substrate (outside apps/, not auto-applied). Modeled on the canonical
# default-deny + allowlist shape in apps/fc-devicemgmt/network-policy.yaml.
#
# Purpose: when a public multi-tenant site is provisioned, each tenant's pods
# get a NetworkPolicy that (a) default-denies all ingress/egress, then allows
# only Traefik ingress + CoreDNS + that tenant's own DB. This enforces the
# cross-tenant isolation Wh-C2 verifies with negative suites.
#
# Replace the {{TENANT}} placeholders and apply alongside the tenant's workload
# (the MySQL/PHP managers should emit this when they create a tenant, or a
# templating step in apps/ should render it). Kept here as the reference shape.
# ----------------------------------------------------------------------------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tenant-{{TENANT}}-isolation
namespace: fc-tenant-{{TENANT}}
labels:
app.kubernetes.io/part-of: flowercore
flowercore.io/tenant-id: "{{TENANT}}"
flowercore.io/created-by: bluejay-infra
flowercore.io/gate: public-tls
spec:
podSelector: {} # all pods in the tenant namespace
policyTypes: [Ingress, Egress]
ingress:
# Only Traefik may reach tenant pods (public traffic terminates at Traefik).
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
ports:
- { protocol: TCP, port: 80 }
- { protocol: TCP, port: 443 }
- { protocol: TCP, port: 8080 }
egress:
# CoreDNS resolution.
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
# This tenant's OWN MySQL only (NOT other tenants' DBs — that's the isolation).
- to:
- podSelector:
matchLabels:
flowercore.io/tenant-id: "{{TENANT}}"
app.kubernetes.io/name: mysql
ports:
- { protocol: TCP, port: 3306 }
# NOTE: deliberately NO blanket egress. Add per-tenant allowances explicitly
# (object storage, mail relay, etc.) so a compromised tenant pod cannot reach
# the rest of the fleet or other tenants.

View File

@@ -0,0 +1,206 @@
using FluentAssertions;
using Xunit;
namespace BluejayInfraLint.Tests;
[Trait("Category", "Unit")]
public sealed class DivoomPiDeployArtifactTests
{
private static readonly string Root = FindRepoRoot();
private static readonly string DmRoot = Path.Combine(Root, "apps", "fc-divoom-dm-pi-device");
private static readonly string TvRoot = Path.Combine(Root, "apps", "fc-divoom-tv-pi");
public static TheoryData<string> DmRequiredArtifacts => new()
{
"README.md",
"hiera/edge2-divoom-dm-device.overlay.yaml",
"puppet/profile/pi/service/divoom_dm_device.pp",
"puppet/templates/divoom-device-registration.json.epp",
"puppet/templates/flowercore-divoom-dm-agent.service.epp",
};
public static TheoryData<string> TvRequiredArtifacts => new()
{
"README.md",
"hiera/example-divoom-tv-pi.iamworkin.lan.yaml",
"puppet/profile/pi/service/divoom_tv.pp",
"systemd/flowercore-divoom-tv.service",
"systemd/flowercore-divoom-tv-hdmi.service",
"systemd/99-flowercore-divoom-tv-hdmi.rules",
"scripts/flowercore-divoom-tv-prelaunch.sh",
"scripts/flowercore-divoom-tv-launch.sh",
"scripts/flowercore-divoom-tv-hdmi-respond.sh",
};
[Theory]
[MemberData(nameof(DmRequiredArtifacts))]
public void DmDeviceArtifacts_ArePresent(string relativePath)
{
File.Exists(Path.Combine(DmRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(relativePath);
}
[Theory]
[MemberData(nameof(TvRequiredArtifacts))]
public void TvPiArtifacts_ArePresent(string relativePath)
{
File.Exists(Path.Combine(TvRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(relativePath);
}
[Fact]
public void DmDeviceReadme_DeclaresPuppetSystemdNotKubernetes()
{
var readme = ReadDm("README.md");
readme.Should().Contain("not a Kubernetes application");
readme.Should().Contain("profile::pi::service::divoom");
readme.Should().Contain("no K8s surface");
}
[Fact]
public void DmHieraOverlay_PreservesExistingEdge2DivoomService()
{
var hiera = ReadDm("hiera/edge2-divoom-dm-device.overlay.yaml");
hiera.Should().Contain("fc-pimanager:");
hiera.Should().Contain("fc-divoom:");
hiera.Should().Contain("enabled: true");
hiera.Should().Contain("profile::pi::service::divoom_dm_device::service_enabled: false");
hiera.Should().Contain("profile::pi::service::divoom_dm_device::service_ensure: 'stopped'");
}
[Fact]
public void DmPuppetProfile_DefaultsToStoppedDisabledService()
{
var profile = ReadDm("puppet/profile/pi/service/divoom_dm_device.pp");
profile.Should().Contain("Boolean $service_enabled = false");
profile.Should().Contain("Enum['running', 'stopped'] $service_ensure = 'stopped'");
profile.Should().Contain("service { $service_name:");
profile.Should().Contain("ensure => $service_ensure");
profile.Should().Contain("enable => $service_enabled");
}
[Fact]
public void DmPuppetProfile_DoesNotManageLiveDivoomWebUnit()
{
var profile = ReadDm("puppet/profile/pi/service/divoom_dm_device.pp");
profile.Should().NotContain("Service['flowercore-divoom.service']");
profile.Should().NotContain("service { 'flowercore-divoom.service'");
profile.Should().NotContain("notify => Service");
}
[Fact]
public void DmAgentUnit_IsSeparateAndGatedByExistingWrappers()
{
var unit = ReadDm("puppet/templates/flowercore-divoom-dm-agent.service.epp");
unit.Should().Contain("ConditionPathExists=<%= $divoom_install_dir %>/bt-link.sh");
unit.Should().Contain("ConditionPathExists=<%= $divoom_install_dir %>/bt-reset.sh");
unit.Should().Contain("ConditionPathExists=<%= $divoom_install_dir %>/audio-link.sh");
unit.Should().Contain("ExecStart=<%= $agent_binary_path %> --mode=Pi");
unit.Should().NotContain("flowercore-divoom.service");
}
[Fact]
public void DmRegistration_CarriesRenderProofAndSafetyPolicy()
{
var registration = ReadDm("puppet/templates/divoom-device-registration.json.epp");
registration.Should().Contain("\"candidateChannels\": <%= $bt_channels_json %>");
registration.Should().Contain("\"deviceInfoIsRenderProof\": false");
registration.Should().Contain("\"visibleRenderProofRequired\": <%= $visible_render_proof_required %>");
registration.Should().Contain("\"preserveExistingService\": \"flowercore-divoom.service\"");
registration.Should().Contain("\"doNotEnableFmRadio\": true");
}
[Fact]
public void TvService_UsesAvaloniaHdmiSafetyGates()
{
var unit = ReadTv("systemd/flowercore-divoom-tv.service");
unit.Should().Contain("ConditionPathExists=/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv");
unit.Should().Contain("Environment=XDG_RUNTIME_DIR=/run/fc-divoom-tv");
unit.Should().Contain("RuntimeDirectoryMode=0700");
unit.Should().Contain("ExecStartPre=/usr/local/bin/flowercore-divoom-tv-prelaunch.sh");
unit.Should().Contain("ExecStart=/usr/local/bin/flowercore-divoom-tv-launch.sh");
unit.Should().Contain("MemoryMax=2G");
unit.Should().Contain("PrivateTmp=true");
unit.Should().NotContain("/tmp");
}
[Fact]
public void TvLauncher_PrefersCageAndFallsBackToDirectLaunch()
{
var script = ReadTv("scripts/flowercore-divoom-tv-launch.sh");
script.Should().Contain("command -v cage");
script.Should().Contain("exec cage --");
script.Should().Contain("launching FlowerCore.Divoom.Tv directly");
script.Should().Contain("--target=hdmi");
script.Should().Contain("--presentation-mode=${PRESENTATION_MODE}");
}
[Fact]
public void TvHotplugRule_SettlesAndRestartsRenderer()
{
var rule = ReadTv("systemd/99-flowercore-divoom-tv-hdmi.rules");
var responder = ReadTv("scripts/flowercore-divoom-tv-hdmi-respond.sh");
rule.Should().Contain("KERNEL==\"card?-HDMI-A-?\"");
rule.Should().Contain("start flowercore-divoom-tv-hdmi.service");
responder.Should().Contain("sleep 2");
responder.Should().Contain("systemctl restart flowercore-divoom-tv.service");
}
[Fact]
public void TvPuppetProfile_InstallsCageAndStaticArtifacts()
{
var profile = ReadTv("puppet/profile/pi/service/divoom_tv.pp");
profile.Should().Contain("package { ['cage', 'libgbm1', 'libdrm2', 'libxkbcommon0', 'fonts-dejavu-core']");
profile.Should().Contain("'profile/pi/fc_divoom_tv/flowercore-divoom-tv.service'");
profile.Should().Contain("'profile/pi/fc_divoom_tv/flowercore-divoom-tv-launch.sh'");
profile.Should().Contain("profile/pi/fc_divoom_tv/99-flowercore-divoom-tv-hdmi.rules");
profile.Should().Contain("Boolean $service_enabled = false");
}
[Fact]
public void DivoomArtifacts_DoNotAddKubernetesWorkloads()
{
var allText = Directory.GetFiles(DmRoot, "*", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(TvRoot, "*", SearchOption.AllDirectories))
.Select(File.ReadAllText);
foreach (var text in allText)
{
text.Should().NotContain("kind: Deployment");
text.Should().NotContain("kind: IngressRoute");
text.Should().NotContain("kind: Certificate");
text.Should().NotContain("kind: OnePasswordItem");
}
}
private static string ReadDm(string relativePath)
=> File.ReadAllText(Path.Combine(DmRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
private static string ReadTv(string relativePath)
=> File.ReadAllText(Path.Combine(TvRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
private static string FindRepoRoot()
{
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 bluejay-infra root.");
}
}

View File

@@ -15,24 +15,19 @@ public sealed class FleetManifestLintTests
{
"brochure.flowercore.io",
"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}
// 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.
// 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",
"update.flowercore.io",
"updates.flowercore.io",
};
private static readonly HashSet<string> ApiKeyProtectedDeployments = new(StringComparer.Ordinal)
@@ -70,7 +65,7 @@ public sealed class FleetManifestLintTests
["github-runner-updater"] = "https://github.com/astoltz/FlowerCore.Updater",
};
private static readonly HashSet<string> ScaledLinuxRunnerDeployments = new(StringComparer.Ordinal)
private static readonly HashSet<string> RepoScopedLinuxRunnerDeployments = new(StringComparer.Ordinal)
{
"github-runner-sharedpos",
"github-runner-puppet",
@@ -84,6 +79,44 @@ public sealed class FleetManifestLintTests
"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",
@@ -227,50 +260,6 @@ public sealed class FleetManifestLintTests
violations.Should().BeEmpty();
}
[Fact]
public void SignalControlDeployment_MustKeepAuthOffAndStageOidcSecret()
{
var deployment = Inventory.Documents.Single(document =>
document.Kind == "Deployment"
&& document.Namespace == "fc-signalcontrol"
&& document.Name == "signalcontrol-web"
&& document.RelativePath == "fc-signalcontrol/fc-signalcontrol.yaml");
var container = deployment.MainContainerMappings().Single(container =>
ManifestNodeExtensions.Scalar(container, "name") == "signalcontrol-web");
EnvValue(container, "Auth__Enabled").Should().Be("false");
EnvValue(container, "FlowerCore__Auth__Enabled").Should().Be("false");
EnvValue(container, "FlowerCore__Auth__Oidc__Enabled").Should().Be("true");
EnvSecretName(container, "FlowerCore__Auth__Oidc__Authority").Should().Be("signalcontrol-oidc-client");
EnvSecretKey(container, "FlowerCore__Auth__Oidc__Authority").Should().Be("issuer_url");
EnvSecretName(container, "FlowerCore__Auth__Oidc__ClientId").Should().Be("signalcontrol-oidc-client");
EnvSecretKey(container, "FlowerCore__Auth__Oidc__ClientId").Should().Be("client_id");
EnvSecretName(container, "FlowerCore__Auth__Oidc__ClientSecret").Should().Be("signalcontrol-oidc-client");
EnvSecretKey(container, "FlowerCore__Auth__Oidc__ClientSecret").Should().Be("client_secret");
EnvSecretOptional(container, "FlowerCore__Auth__Oidc__Authority").Should().BeTrue();
EnvSecretOptional(container, "FlowerCore__Auth__Oidc__ClientId").Should().BeTrue();
EnvSecretOptional(container, "FlowerCore__Auth__Oidc__ClientSecret").Should().BeTrue();
}
[Fact]
public void SignalControlDeployment_MustWirePirelayRelayBridgeSecret()
{
var deployment = Inventory.Documents.Single(document =>
document.Kind == "Deployment"
&& document.Namespace == "fc-signalcontrol"
&& document.Name == "signalcontrol-web"
&& document.RelativePath == "fc-signalcontrol/fc-signalcontrol.yaml");
var container = deployment.MainContainerMappings().Single(container =>
ManifestNodeExtensions.Scalar(container, "name") == "signalcontrol-web");
EnvValue(container, "TrafficSignal__RelayBridge__Enabled").Should().Be("true");
EnvValue(container, "TrafficSignal__RelayBridge__BaseUrl").Should().Be("https://pirelay.iamworkin.lan");
EnvSecretName(container, "TrafficSignal__RelayBridge__ApiKey").Should().Be("signalcontrol-pirelay");
EnvSecretKey(container, "TrafficSignal__RelayBridge__ApiKey").Should().Be("ApiKey");
EnvSecretOptional(container, "TrafficSignal__RelayBridge__ApiKey").Should().BeTrue();
EnvValue(container, "LiveStatus__TrafficSignal__BaseAddress").Should().Be("https://signalcontrol.iamworkin.lan");
}
[Fact]
public void GitHubRunnerFleet_MustRegisterRequiredReposAsRepoScopedDeployments()
{
@@ -283,6 +272,7 @@ public sealed class FleetManifestLintTests
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, "DISABLE_AUTO_UPDATE").Should().Be("true", $"{expectedRunner.Key} must not self-update inside immutable Kubernetes runner pods");
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");
@@ -316,17 +306,17 @@ public sealed class FleetManifestLintTests
}
[Fact]
public void GitHubRunnerFleet_MustAvoidRwoMultiAttachForScaledDeployments()
public void GitHubRunnerFleet_MustAvoidRwoMultiAttachForRepoScopedDeployments()
{
var deployments = GitHubRunnerDeployments();
foreach (var deploymentName in ScaledLinuxRunnerDeployments)
foreach (var deploymentName in RepoScopedLinuxRunnerDeployments)
{
var deployment = deployments[deploymentName];
// Scaled runners must have >= 2 replicas (avoid single-pod bottleneck).
// Individual deployments may be tuned upward per CI activity — see
// "runners: right-size replica counts per 14d CI activity (#24)".
ReplicaCount(deployment).Should().BeGreaterOrEqualTo(2, $"{deploymentName} is in the scaled set and must run with at least 2 replicas");
// 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
@@ -334,7 +324,7 @@ public sealed class FleetManifestLintTests
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToList();
claimNames.Should().BeEmpty($"{deploymentName} is scaled and must not share a RWO PVC");
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);
@@ -469,33 +459,79 @@ public sealed class FleetManifestLintTests
}
[Fact]
public void Monitoring_MustScrapeSignalControlPiAppAndMountDashboard()
public void Monitoring_GenericKubernetesAlerts_MustExcludeEphemeralGithubRunnerNamespace()
{
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
monitoring.Should().Contain("job_name: \"signalcontrol-pi-app\"");
monitoring.Should().Contain("metrics_path: /metrics/prometheus");
monitoring.Should().Contain("10.0.58.113:5200");
monitoring.Should().Contain("host: \"signal-a.iamworkin.lan\"");
monitoring.Should().Contain("mountPath: /var/lib/grafana/dashboards/signalcontrol");
monitoring.Should().Contain("name: grafana-dashboard-signalcontrol");
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 SignalControlGrafanaDashboard_MustCoverAppNodeAndPhysicalControlMetrics()
public void Monitoring_BlackboxTargetsForOidcSensitiveServices_MustUseAnonymousHealthRoutesWhenAvailable()
{
var dashboard = File.ReadAllText(Path.Combine(
Inventory.BluejayRoot,
"apps",
"monitoring",
"grafana-dashboard-signalcontrol.yaml"));
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
dashboard.Should().Contain("uid\": \"flowercore-signalcontrol\"");
dashboard.Should().Contain("up{job=\\\"signalcontrol-pi-app\\\",instance=\\\"pirelay\\\"}");
dashboard.Should().Contain("up{job=\\\"edge-nodes\\\",instance=\\\"pirelay\\\"}");
dashboard.Should().Contain("signal_relay_writes_total");
dashboard.Should().Contain("signal_schedule_fires_total");
dashboard.Should().Contain("signalcontrol_screen_saver_enabled");
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]
@@ -611,10 +647,10 @@ public sealed class FleetManifestLintTests
var expectedFiles = new[]
{
"1password-item.yaml",
"argocd-application.yaml",
"certificate-web.yaml",
"clusterrole-operator.yaml",
"clusterrolebinding-operator.yaml",
"crds.yaml",
"deployment-operator.yaml",
"deployment-web.yaml",
"ingressroute-web.yaml",
@@ -704,7 +740,8 @@ public sealed class FleetManifestLintTests
.Single(document => document.Kind == "ClusterRole" && document.Name == "fc-devicemgmt-operator");
var allScalars = clusterRole.AllScalars().ToList();
allScalars.Should().Contain("devices.flowercore.io");
allScalars.Should().Contain("flowercore.io");
allScalars.Should().NotContain("devices.flowercore.io");
allScalars.Should().Contain("*");
allScalars.Should().Contain("deployments");
allScalars.Should().Contain("get");
@@ -733,7 +770,7 @@ public sealed class FleetManifestLintTests
FcDeviceManagementDocuments().Should().NotContain(document => document.Kind == "Secret");
appText.Should().Contain("secretKeyRef:");
appText.Should().Contain("secretName: fc-devicemgmt-runtime");
appText.Should().Contain("name: fc-devicemgmt-runtime");
appText.Should().NotContain("stringData:");
appText.Should().NotContain("from-literal");
appText.Should().NotContain("tls.key:");
@@ -767,17 +804,202 @@ 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");
var documents = FcDeviceManagementDocuments();
application.Namespace.Should().Be("argocd");
application.Scalar("spec", "source", "repoURL")
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", AuthEnabled: "false"),
(App: "fc-media", Name: "fc-media-web", Slug: "media", Secret: "media-oidc-client", AuthEnabled: "true"),
(App: "fc-distribution", Name: "fc-distribution", Slug: "distribution", Secret: "distribution-oidc-client", AuthEnabled: "true"),
};
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(expected.AuthEnabled);
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:v20260613-g5-quota-aa99bd1");
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()
.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");
.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(
@@ -836,14 +1058,23 @@ public sealed class FleetManifestLintTests
: null;
}
private static bool EnvSecretOptional(YamlMappingNode container, string name)
private static string? EnvSecretOptional(YamlMappingNode container, string name)
{
return string.Equals(
EnvMapping(container, name) is { } env
? ManifestNodeExtensions.Scalar(env, "valueFrom", "secretKeyRef", "optional")
: null,
"true",
StringComparison.Ordinal);
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)
@@ -852,6 +1083,19 @@ public sealed class FleetManifestLintTests
.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