Compare commits

..

1 Commits

Author SHA1 Message Date
Codex
05af0c48f9 Mirror voice stack monitoring assets 2026-05-06 17:35:59 -05:00
67 changed files with 190 additions and 9251 deletions

View File

@@ -1,18 +0,0 @@
# Authentik OIDC client registration sweep
This directory holds the FlowerCore per-service OIDC client secret references
for the ADR-093 / ADR-124 Phase 1 step 8 sweep.
The `clients/*-oidc-client.yaml` manifests are intentionally only
`OnePasswordItem` CRDs. The actual 1Password items are created by an operator in
the `IAmWorkin` vault with these fields:
| Field | Purpose |
| --- | --- |
| `client_id` | Authentik provider client id, default `<slug>` |
| `client_secret` | Authentik provider client secret |
| `issuer_url` | `https://id.iamworkin.lan/application/o/<slug>/` |
Run `scripts/authentik-bulk-client-create.py` in dry-run mode first. Live REST
mutation requires `--apply`, `AUTHENTIK_TOKEN`, and an operator-provided
client-secret JSON file. The script redacts secrets in all normal output.

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: aistation-oidc-client
namespace: fc-aistation
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: aistation
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/aistation-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/aistation-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: audit-oidc-client
namespace: fc-audit
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: audit
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/audit-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/audit-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: chat-oidc-client
namespace: fc-chat
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: chat
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/chat-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/chat-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: distribution-oidc-client
namespace: fc-distribution
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: distribution
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/distribution-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/distribution-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: dms-oidc-client
namespace: fc-dms
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: dms
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/dms-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/dms-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: dns-oidc-client
namespace: fc-dns
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: dns
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/dns-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/dns-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: intranet-oidc-client
namespace: intranet
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: intranet
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/intranet-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/intranet-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: irc-oidc-client
namespace: irc
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: irc
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/irc-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/irc-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: kiosk-oidc-client
namespace: fc-system
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: kiosk
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/kiosk-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/kiosk-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: knowledge-oidc-client
namespace: knowledge
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: knowledge
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/knowledge-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/knowledge-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: library-oidc-client
namespace: fc-library
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: library
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/library-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/library-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: licensing-oidc-client
namespace: fc-licensing
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: licensing
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/licensing-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/licensing-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: llmbridge-oidc-client
namespace: fc-llm-bridge
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: llmbridge
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/llmbridge-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/llmbridge-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: media-oidc-client
namespace: fc-media
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: media
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/media-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/media-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: menuboard-oidc-client
namespace: fc-menuboard
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: menuboard
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/menuboard-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/menuboard-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: messageboard-oidc-client
namespace: fc-messageboard
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: messageboard
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/messageboard-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/messageboard-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: mike-bundle-oidc-client
namespace: fc-mike-bundle
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: mike-bundle
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/mike-bundle-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/mike-bundle-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: mndot-oidc-client
namespace: fc-mndot
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: mndot
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/mndot-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/mndot-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: mysql-oidc-client
namespace: fc-mysql
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: mysql
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/mysql-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/mysql-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: php-oidc-client
namespace: fc-php
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: php
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/php-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/php-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: pimanager-oidc-client
namespace: fc-pimanager
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: pimanager
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/pimanager-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/pimanager-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: presentations-oidc-client
namespace: fc-presentations
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: presentations
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/presentations-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/presentations-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: print-oidc-client
namespace: fc-print
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: print
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/print-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/print-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: provisioning-oidc-client
namespace: fc-provisioning
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: provisioning
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/provisioning-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/provisioning-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: remotedesktop-oidc-client
namespace: fc-desktop
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: remotedesktop
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/remotedesktop-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/remotedesktop-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: retail-oidc-client
namespace: fc-retail
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: retail
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/retail-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/retail-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: scoreboards-oidc-client
namespace: fc-scoreboard
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: scoreboards
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/scoreboards-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/scoreboards-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: segmentdisplay-oidc-client
namespace: fc-segmentdisplay
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: segmentdisplay
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/segmentdisplay-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/segmentdisplay-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: signage-oidc-client
namespace: fc-signage
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: signage
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/signage-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/signage-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: signalcontrol-oidc-client
namespace: fc-signalcontrol
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: signalcontrol
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/signalcontrol-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/signalcontrol-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: telephony-oidc-client
namespace: telephony
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: telephony
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/telephony-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/telephony-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: ttsreader-oidc-client
namespace: fc-ttsreader
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: ttsreader
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/ttsreader-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/ttsreader-oidc-client"

View File

@@ -1,14 +0,0 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: worldbuilder-oidc-client
namespace: fc-worldbuilder
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/component: authentik-oidc-client
flowercore.io/authentik-client-slug: worldbuilder
annotations:
flowercore.io/onepassword-item: "IAmWorkin/items/worldbuilder-oidc-client"
flowercore.io/expected-fields: "client_id,client_secret,issuer_url"
spec:
itemPath: "vaults/IAmWorkin/items/worldbuilder-oidc-client"

View File

@@ -1,38 +0,0 @@
# ArgoCD's bluejay-infra ApplicationSet sees apps/authentik as one app. Keep
# an explicit resource list so the client manifests can live under clients/.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- clients/library-oidc-client.yaml
- clients/retail-oidc-client.yaml
- clients/telephony-oidc-client.yaml
- clients/knowledge-oidc-client.yaml
- clients/llmbridge-oidc-client.yaml
- clients/mysql-oidc-client.yaml
- clients/php-oidc-client.yaml
- clients/signage-oidc-client.yaml
- clients/media-oidc-client.yaml
- clients/dms-oidc-client.yaml
- clients/pimanager-oidc-client.yaml
- clients/distribution-oidc-client.yaml
- clients/dns-oidc-client.yaml
- clients/print-oidc-client.yaml
- clients/aistation-oidc-client.yaml
- clients/irc-oidc-client.yaml
- clients/ttsreader-oidc-client.yaml
- clients/chat-oidc-client.yaml
- clients/intranet-oidc-client.yaml
- clients/remotedesktop-oidc-client.yaml
- clients/provisioning-oidc-client.yaml
- clients/scoreboards-oidc-client.yaml
- clients/mndot-oidc-client.yaml
- clients/kiosk-oidc-client.yaml
- clients/mike-bundle-oidc-client.yaml
- clients/messageboard-oidc-client.yaml
- clients/menuboard-oidc-client.yaml
- clients/presentations-oidc-client.yaml
- clients/segmentdisplay-oidc-client.yaml
- clients/signalcontrol-oidc-client.yaml
- clients/worldbuilder-oidc-client.yaml
- clients/audit-oidc-client.yaml
- clients/licensing-oidc-client.yaml

View File

@@ -1,69 +0,0 @@
# CDI — Containerized Data Importer
KubeVirt's `containerized-data-importer` for populating PVCs from external
sources (HTTP, HTTPS, container registry, S3, virtctl upload). Required to
import the Windows Server 2025 ISO into the `windows-server-2025-iso` PVC
that `apps/kubevirt-vms/ci1.yaml` mounts as a CDROM.
## Files
| File | Source | Purpose |
| ----------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `cdi-operator.yaml` | [`v1.65.0`](https://github.com/kubevirt/containerized-data-importer/releases/tag/v1.65.0) — verbatim copy | Installs operator + CRDs (5779 lines, large) |
| `cdi-cr.yaml` | [`v1.65.0`](https://github.com/kubevirt/containerized-data-importer/releases/tag/v1.65.0) — annotated + commented | Tells operator to deploy CDI components |
`cdi-operator.yaml` is **vendored verbatim** from the upstream release for
air-gap reproducibility (no internet fetch at deploy time, ArgoCD prune
contracts hold). To bump versions:
```bash
CDI_VER=v1.66.0 # for example
curl -sL "https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VER}/cdi-operator.yaml" \
-o apps/cdi/cdi-operator.yaml
curl -sL "https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VER}/cdi-cr.yaml" \
-o /tmp/cdi-cr-new.yaml # then re-apply project header diff
git diff apps/cdi/ # review
git commit + push
```
## Verify after deploy
```bash
kubectl -n cdi get pods # operator + apiserver + deployment + uploadproxy
kubectl get cdis cdi -o jsonpath='{.status.phase}' # "Deployed"
kubectl get crd | grep cdi.kubevirt.io
# Expected CRDs: datavolumes.cdi.kubevirt.io, cdiconfigs.cdi.kubevirt.io,
# storageprofiles.cdi.kubevirt.io, dataimportcrons.cdi.kubevirt.io,
# datasources.cdi.kubevirt.io, objecttransfers.cdi.kubevirt.io
```
## Use after install
```yaml
# Example DataVolume that imports from HTTP
apiVersion: cdi.kubevirt.io/v1beta1
kind: DataVolume
metadata:
name: my-iso
spec:
source:
http:
url: "https://server/path/to.iso"
pvc:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 10Gi
storageClassName: longhorn
```
```bash
# Or upload from local disk via virtctl
virtctl image-upload pvc my-iso \
--image-path ./my.iso \
--size 10Gi \
--storage-class longhorn \
--access-mode ReadWriteOnce \
--uploadproxy-url https://cdi-uploadproxy.cdi.svc:443 \
--insecure
```

View File

@@ -1,36 +0,0 @@
# =============================================================================
# CDI CR — Tells the CDI operator to install CDI components into the cluster.
# =============================================================================
# After cdi-operator.yaml is applied, the operator watches for THIS resource
# (CDI named "cdi"). When found, it deploys cdi-apiserver, cdi-deployment,
# cdi-uploadproxy, cdi-cronjob, and the importer/uploadserver/cloner pods.
#
# Configuration:
# - HonorWaitForFirstConsumer: PVCs created by DataVolumes wait for first
# pod to schedule before binding (lets storage class pick best node).
# - WebhookPvcRendering: validates PVC creation against CDI policies.
# - imagePullPolicy IfNotPresent: re-pull only on tag rotation.
# - nodeSelector linux: pin to Linux nodes (no Windows worker support).
#
# Andrew may want to add a `uploadProxyURLOverride` later to expose the
# uploadproxy via Traefik IngressRoute for `virtctl image-upload` from
# BLUEJAY-WS without `kubectl port-forward`. Phase 2 enhancement.
# =============================================================================
apiVersion: cdi.kubevirt.io/v1beta1
kind: CDI
metadata:
name: cdi
annotations:
bluejay.iamworkin.lan/source: "kubevirt/containerized-data-importer v1.65.0"
spec:
config:
featureGates:
- HonorWaitForFirstConsumer
- WebhookPvcRendering
imagePullPolicy: IfNotPresent
infra:
nodeSelector:
kubernetes.io/os: linux
workload:
nodeSelector:
kubernetes.io/os: linux

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,5 @@
# FlowerCore Remote Desktop — TLS + Ingress # FlowerCore Remote Desktop — TLS + Ingress
# # Deployment and Service managed by deploy script (not ArgoCD)
# Source-of-truth split:
# - bluejay-infra OWNS: Certificate, IngressRoute, all NetworkPolicies
# (see network-policies.yaml in this directory).
# - FlowerCore.RemoteDesktop scripts/deploy-web.sh OWNS: Deployment +
# Service. Reason: image refs like `localhost/fc-desktop:linux-xfce`
# only exist on each node's containerd after a manual import, so a
# Deployment manifest in bluejay-infra would race the image-import
# step and crash-loop.
#
# NetworkPolicies moved into bluejay-infra 2026-05-07 — previously they
# were applied via the deploy script's kubectl apply calls, which broke
# cluster-rebuild repeatability. See
# feedback_networkpolicies_belong_in_bluejay_infra.md.
--- ---
apiVersion: cert-manager.io/v1 apiVersion: cert-manager.io/v1
kind: Certificate kind: Certificate

View File

@@ -1,332 +0,0 @@
# FlowerCore Remote Desktop — NetworkPolicies (GitOps-managed)
#
# Moved into bluejay-infra 2026-05-07 as part of the regroup audit. These
# four policies were previously applied via FlowerCore.RemoteDesktop's
# scripts/deploy-web.sh `kubectl apply` calls, which meant a fresh cluster
# rebuild from bluejay-infra alone would miss them — Browser Lab session
# isolation, control-plane allow-list, and HTTP-01 cert renewal would all
# silently fail to come up.
#
# Source-of-truth contract:
# - bluejay-infra OWNS all NetworkPolicy + Certificate + IngressRoute
# resources for fc-desktop.
# - FlowerCore.RemoteDesktop's scripts/deploy-web.sh continues to own
# the Deployment + Service apply (because the image ref
# `localhost/fc-desktop:linux-xfce` only exists on each node's
# containerd after a manual import — it can't be pulled from a
# registry, so a Deployment manifest in bluejay-infra would race the
# image-import step and crash-loop).
---
# 1) desktop-isolation — Browser Lab session pods.
#
# Locks down pods labeled `app.kubernetes.io/name=remote-desktop` (every
# session pod regardless of template). Allows guacd ingress for the VNC/RDP
# display lane and remotedesktop-web's pre-handoff probing. Egress: NFS to
# Synology, DNS, Traefik (cluster + LB VIP), Intranet (Browser Lab home).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: desktop-isolation
namespace: fc-desktop
labels:
app.kubernetes.io/part-of: remotedesktop
app.kubernetes.io/component: isolation
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: remote-desktop
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: guacamole
ports:
- port: 3000
protocol: TCP
- port: 3001
protocol: TCP
- port: 5901
protocol: TCP
- port: 3389
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-desktop
podSelector:
matchLabels:
app.kubernetes.io/name: remotedesktop-web
ports:
- port: 3000
protocol: TCP
- port: 5901
protocol: TCP
egress:
# NFS to Synology
- to:
- ipBlock:
cidr: 10.0.58.3/32
ports:
- port: 2049
protocol: TCP
- port: 2049
protocol: UDP
- port: 111
protocol: TCP
- port: 111
protocol: UDP
- to:
- ipBlock:
cidr: 10.0.58.3/32
ports:
- port: 445
protocol: TCP
- to: []
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
- to:
- ipBlock:
cidr: 10.0.56.200/32
- ipBlock:
cidr: 10.43.33.87/32
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- port: 80
protocol: TCP
- port: 443
protocol: TCP
- port: 8000
protocol: TCP
- port: 8443
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: intranet
podSelector:
matchLabels:
app: intranet-web
ports:
- port: 5300
protocol: TCP
---
# 2) fc-desktop-default-deny — namespace-wide catch-all.
#
# Selects every pod EXCEPT remotedesktop-web (the public-surface control
# plane) and applies default-deny semantics for both Ingress and Egress.
# Closes the gap where session pods land WITHOUT the desktop-isolation
# policy's `app.kubernetes.io/name=remote-desktop` label, plus prevents
# arbitrary debug sidecars / kubectl debug images from getting cluster
# access.
#
# CRITICAL: also catches transient cm-acme-http-solver pods (that's the
# bug this whole regroup chased). The cm-acme-http-solver-allow policy
# below is the explicit carve-out.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: fc-desktop-default-deny
namespace: fc-desktop
labels:
app.kubernetes.io/part-of: remotedesktop
app.kubernetes.io/component: isolation
spec:
podSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: NotIn
values:
- remotedesktop-web
policyTypes:
- Ingress
- Egress
---
# 3) remotedesktop-web-isolation — control plane explicit allow-list.
#
# remotedesktop-web is the only pod label the default-deny excludes, so
# without this policy the control plane would have wide-open Ingress AND
# Egress. This re-introduces a tight allow-list:
# - Ingress: Traefik only on TCP/8080
# - Egress: CoreDNS, K8s API, Guacamole admin, NFS, Intranet,
# Traefik (cluster + LB), and the fc-desktop namespace itself
# (for session pod readiness probing).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: remotedesktop-web-isolation
namespace: fc-desktop
labels:
app.kubernetes.io/part-of: remotedesktop
app.kubernetes.io/component: isolation
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: remotedesktop-web
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- port: 8080
protocol: TCP
egress:
# CoreDNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# K8s API server
- to: []
ports:
- port: 443
protocol: TCP
- port: 6443
protocol: TCP
# Guacamole admin
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: guacamole
ports:
- port: 8080
protocol: TCP
# NFS to Synology
- to:
- ipBlock:
cidr: 10.0.58.3/32
ports:
- port: 2049
protocol: TCP
- port: 2049
protocol: UDP
- port: 111
protocol: TCP
- port: 111
protocol: UDP
# Intranet web
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: intranet
podSelector:
matchLabels:
app: intranet-web
ports:
- port: 5300
protocol: TCP
# Cluster Traefik pods (in-cluster service resolution + Guacamole
# routing handoff where web app builds URLs against the public host
# but resolves internally).
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- port: 80
protocol: TCP
- port: 443
protocol: TCP
- port: 8080
protocol: TCP
- port: 8443
protocol: TCP
# fc-desktop namespace — session pod probing during browser-access
# readiness checks.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-desktop
ports:
- port: 3000
protocol: TCP
- port: 3001
protocol: TCP
- port: 5901
protocol: TCP
- port: 3389
protocol: TCP
---
# 4) cm-acme-http-solver-allow — cert-manager HTTP-01 carve-out.
#
# Without this, fc-desktop-default-deny catches the transient solver pods
# cert-manager creates for each renewal (they don't carry the
# remotedesktop-web label). Caused 8-day silent renewal failure on
# desktop.iamworkin.lan in 2026-04-28..2026-05-07 (see
# feedback_certmanager_renewal_stuck_when_solver_blocked_by_namespace_default_deny.md).
#
# Authorizes:
# - Ingress on TCP/8089 from cluster Traefik (which proxies the external
# HTTP-01 GET on port 80 through to the solver).
# - Egress for cluster DNS (defensive — newer cert-manager probes from
# inside the solver too).
#
# The `acme.cert-manager.io/http01-solver=true` label is set by
# cert-manager itself on every solver pod automatically.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: cm-acme-http-solver-allow
namespace: fc-desktop
labels:
app.kubernetes.io/part-of: remotedesktop
app.kubernetes.io/component: cert-renewal
spec:
podSelector:
matchLabels:
acme.cert-manager.io/http01-solver: "true"
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- port: 8089
protocol: TCP
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP

View File

@@ -118,7 +118,7 @@ spec:
# dotnet.exe publish -c Release -o deploy/app \ # dotnet.exe publish -c Release -o deploy/app \
# src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj # src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
# podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy # podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy
image: localhost/fc-distribution:v202605061948 image: localhost/fc-distribution:v202604240010
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- containerPort: 8080 - containerPort: 8080
@@ -151,10 +151,6 @@ spec:
value: "/signing/aistation-field/chain.pem" value: "/signing/aistation-field/chain.pem"
- name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__KeyPath - name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__KeyPath
value: "/signing/aistation-field/private-key.pem" value: "/signing/aistation-field/private-key.pem"
# Public distribution host is GET/HEAD-only at Traefik; this
# entitlement list controls which editions are readable there.
- name: FlowerCore__Distribution__EntitlementPublic__PublicEditions__0
value: "*"
resources: resources:
requests: requests:
cpu: 100m cpu: 100m
@@ -266,12 +262,8 @@ spec:
kind: ClusterIssuer kind: ClusterIssuer
dnsNames: dnsNames:
- dist.iamworkin.lan - dist.iamworkin.lan
# step-ca ACME caps lifetime at 30d; requesting 90d silently capped duration: 2160h # 90d
# made renewBefore=cert-lifetime → perpetual renewal loop (10880+ CRs renewBefore: 720h # 30d
# in 18h on 2026-05-07). Match working 720h/240h pattern from other
# FC services.
duration: 720h # 30d (step-ca cap)
renewBefore: 240h # 10d
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute

View File

@@ -1,171 +0,0 @@
# fc-redis — SignalR backplane for cross-product event bus
#
# Lands per Q-SO-1 resolution (2026-05-11 PM): SignalR backplane in Phase A,
# not Phase C as originally drafted. Operator directive: "Redis can be
# deployed just fine as it's another FlowerCore technology we'll want to
# manage."
#
# Phase A scope (this file):
# - Single Redis 7.x Alpine pod
# - 1Gi Longhorn RWO PVC for AOF persistence
# - ClusterIP Service at `redis.fc-redis.svc.cluster.local:6379`
# - No AUTH (in-cluster only; not exposed externally)
# - No IngressRoute (backplane is server-to-server only)
#
# Consumers (Phase A IMPL across FC services):
# - FlowerCore.Signage.Web (OpsConsoleHub)
# - FlowerCore.Scoreboard.Web (ScoreboardHub)
# - FlowerCore.SignalControl.Web
# - FlowerCore.DMS.Web
# - Any other product joining the cross-product event bus
#
# Each consumer adds:
# services.AddSignalR()
# .AddStackExchangeRedis(
# "redis.fc-redis.svc.cluster.local:6379",
# opts => opts.Configuration.ChannelPrefix =
# StackExchange.Redis.RedisChannel.Literal("fc-opsconsole"));
#
# Phase B / C follow-ons (out of scope here):
# - Redis Sentinel for HA (3-node)
# - AUTH password from 1Password Connect (rotate via /rotate-password)
# - redis_exporter sidecar for Prometheus scrape
# - Network policies restricting which namespaces can dial 6379
#
# Design: docs/signage/operations-console-phase-2-design.md §3.5
# Decision: Q-SO-1 (RESOLVED 2026-05-11 PM)
# Memory: feedback_blooming_ui_pattern_no_iframes
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-redis
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fc-redis-data
namespace: fc-redis
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-redis-config
namespace: fc-redis
data:
redis.conf: |
# Phase A — minimal config; no AUTH, no replication.
bind 0.0.0.0
protected-mode no
port 6379
tcp-backlog 511
timeout 0
tcp-keepalive 300
# Persistence: AOF (fsync every second is the standard SignalR-backplane
# durability sweet spot — the backplane only needs to survive Redis
# restarts, not absolute zero loss).
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# Reasonable defaults — let Redis pick most things.
maxmemory-policy allkeys-lru
maxmemory 256mb
# Logging
loglevel notice
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-redis
namespace: fc-redis
labels:
app: fc-redis
spec:
replicas: 1
strategy:
type: Recreate # RWO PVC; do not do rolling update
selector:
matchLabels:
app: fc-redis
template:
metadata:
labels:
app: fc-redis
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999 # redis:7-alpine default uid
runAsGroup: 999
fsGroup: 999
containers:
- name: redis
image: redis:7-alpine
imagePullPolicy: IfNotPresent
command: ["redis-server", "/etc/redis/redis.conf"]
ports:
- name: redis
containerPort: 6379
resources:
requests:
cpu: "50m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "384Mi"
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /etc/redis
readOnly: true
livenessProbe:
tcpSocket:
port: 6379
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 2
periodSeconds: 5
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
volumes:
- name: data
persistentVolumeClaim:
claimName: fc-redis-data
- name: config
configMap:
name: fc-redis-config
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: fc-redis
spec:
type: ClusterIP
selector:
app: fc-redis
ports:
- name: redis
port: 6379
targetPort: 6379
protocol: TCP

View File

@@ -30,7 +30,6 @@ import logging
import re import re
import shlex import shlex
import subprocess import subprocess
import unicodedata
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
@@ -61,189 +60,6 @@ class TtsRequest(BaseModel):
volume: int = 100 # 0-200 volume: int = 100 # 0-200
HEBREW_CHAR_RE = re.compile(r"[\u0590-\u05FF]")
HEBREW_WORD_RE = re.compile(r"[\u0590-\u05FF]+")
# eSpeak-NG's Hebrew voice can spell unpointed Hebrew as Unicode character
# names on some builds. For source-text study reads, prefer a stable
# scholarly transliteration so words sound like words even without niqqud.
HEBREW_WORD_TRANSLITERATIONS = {
"אב": "av",
"אבא": "abba",
"אברהם": "Avraham",
"אדמה": "adamah",
"אדני": "Adonai",
"אדם": "adam",
"אור": "or",
"אלהים": "Elohim",
"אלוהים": "Elohim",
"אמן": "amen",
"אם": "em",
"אמת": "emet",
"ארץ": "eretz",
"אש": "esh",
"את": "et",
"בית": "beit",
"בן": "ben",
"ברא": "bara",
"בראשית": "bereshit",
"ברית": "berit",
"ברוך": "barukh",
"בת": "bat",
"גוי": "goy",
"גוים": "goyim",
"גויים": "goyim",
"דבר": "davar",
"דברים": "devarim",
"דוד": "David",
"הלל": "hallel",
"הארץ": "ha-aretz",
"הברית": "ha-berit",
"החדשה": "ha-chadashah",
"השמים": "ha-shamayim",
"השמיים": "ha-shamayim",
"ויאמר": "vayomer",
"יהוה": "Adonai",
"יוסף": "Yosef",
"יוחנן": "Yochanan",
"ישראל": "Yisrael",
"ישוע": "Yeshua",
"יצחק": "Yitzchak",
"יעקב": "Yaakov",
"ירושלים": "Yerushalayim",
"כהן": "kohen",
"כהנים": "kohanim",
"מים": "mayim",
"מות": "mavet",
"מושיע": "moshia",
"מלך": "melekh",
"מלכות": "malkhut",
"מרים": "Miriam",
"משה": "Moshe",
"משיח": "Mashiach",
"נביא": "navi",
"נביאים": "neviim",
"עם": "am",
"עולם": "olam",
"צדק": "tzedek",
"קדוש": "qadosh",
"קדושים": "qedoshim",
"קול": "qol",
"רוח": "ruach",
"שאול": "Shaul",
"שמים": "shamayim",
"שמיים": "shamayim",
"שמעון": "Shimon",
"שלום": "Shalom",
"תורה": "torah",
"חכמה": "chokhmah",
"חסד": "chesed",
"חיים": "chayim",
"חושך": "choshekh",
}
HEBREW_LETTERS = {
"א": "a",
"ב": "b",
"ג": "g",
"ד": "d",
"ה": "h",
"ו": "v",
"ז": "z",
"ח": "kh",
"ט": "t",
"י": "y",
"כ": "kh",
"ך": "kh",
"ל": "l",
"מ": "m",
"ם": "m",
"נ": "n",
"ן": "n",
"ס": "s",
"ע": "a",
"פ": "p",
"ף": "f",
"צ": "ts",
"ץ": "ts",
"ק": "q",
"ר": "r",
"ש": "sh",
"ת": "t",
}
HEBREW_VOWELISH = {"a", "e", "i", "o", "u"}
def _strip_hebrew_marks(value: str) -> str:
decomposed = unicodedata.normalize("NFD", value)
return "".join(
ch for ch in decomposed
if unicodedata.category(ch) != "Mn" and ch not in {"׳", "״", "־"}
)
def _fallback_hebrew_transliteration(word: str) -> str:
tokens: list[str] = []
chars = list(word)
for index, ch in enumerate(chars):
token = HEBREW_LETTERS.get(ch)
if token is None:
continue
if ch == "ה" and index == len(chars) - 1:
token = "ah"
elif ch == "י" and index > 0:
token = "i"
elif ch == "ו" and index > 0:
token = "o"
tokens.append(token)
if not tokens:
return word
spoken: list[str] = []
for index, token in enumerate(tokens):
spoken.append(token)
next_token = tokens[index + 1] if index + 1 < len(tokens) else ""
if (
token[-1:] not in HEBREW_VOWELISH
and next_token
and next_token[:1] not in HEBREW_VOWELISH
):
spoken.append("a")
return "".join(spoken)
def _transliterate_hebrew_word(match: re.Match[str]) -> str:
original = match.group(0)
normalized = _strip_hebrew_marks(original)
if not normalized:
return original
direct = HEBREW_WORD_TRANSLITERATIONS.get(normalized)
if direct:
return direct
if normalized.startswith("ו") and len(normalized) > 1:
rest = HEBREW_WORD_TRANSLITERATIONS.get(normalized[1:])
if rest:
return f"ve-{rest}"
if normalized.startswith("ה") and len(normalized) > 1:
rest = HEBREW_WORD_TRANSLITERATIONS.get(normalized[1:])
if rest:
return f"ha-{rest}"
return _fallback_hebrew_transliteration(normalized)
def _prepare_synthesis_input(text: str, language: str, voice: str) -> tuple[str, str]:
if language.lower().startswith("he") and HEBREW_CHAR_RE.search(text):
spoken = HEBREW_WORD_RE.sub(_transliterate_hebrew_word, text)
return spoken, "en-us"
return text, voice
def _resolve_voice(req: TtsRequest) -> str: def _resolve_voice(req: TtsRequest) -> str:
if req.voice: if req.voice:
return req.voice.strip() return req.voice.strip()
@@ -299,15 +115,14 @@ def tts(req: TtsRequest) -> Response:
raise HTTPException(status_code=400, detail="text is required") raise HTTPException(status_code=400, detail="text is required")
voice = _resolve_voice(req) voice = _resolve_voice(req)
spoken_text, synth_voice = _prepare_synthesis_input(req.text, req.language, voice)
args = [ args = [
"--stdout", "--stdout",
"-v", synth_voice, "-v", voice,
"-s", str(max(80, min(450, req.rate))), "-s", str(max(80, min(450, req.rate))),
"-p", str(max(0, min(99, req.pitch))), "-p", str(max(0, min(99, req.pitch))),
"-a", str(max(0, min(200, req.volume))), "-a", str(max(0, min(200, req.volume))),
] ]
wav = _run_espeak(args, spoken_text.encode("utf-8")) wav = _run_espeak(args, req.text.encode("utf-8"))
if not wav: if not wav:
raise HTTPException(status_code=500, detail="espeak-ng returned empty stdout") raise HTTPException(status_code=500, detail="espeak-ng returned empty stdout")
return Response(content=wav, media_type="audio/wav") return Response(content=wav, media_type="audio/wav")
@@ -338,9 +153,9 @@ def tts(req: TtsRequest) -> Response:
PHONEME_DURATION_RE = re.compile(r"^\s*\S+\s+(\d+)\s+", re.MULTILINE) PHONEME_DURATION_RE = re.compile(r"^\s*\S+\s+(\d+)\s+", re.MULTILINE)
def _estimate_total_ms(req: TtsRequest, voice: str, spoken_text: str) -> int: def _estimate_total_ms(req: TtsRequest, voice: str) -> int:
args = ["--pho", "--quiet", "-v", voice, "-s", str(req.rate)] args = ["--pho", "--quiet", "-v", voice, "-s", str(req.rate)]
out = _run_espeak(args, spoken_text.encode("utf-8")) out = _run_espeak(args, req.text.encode("utf-8"))
text = out.decode("utf-8", errors="replace") text = out.decode("utf-8", errors="replace")
total = 0 total = 0
for match in PHONEME_DURATION_RE.finditer(text): for match in PHONEME_DURATION_RE.finditer(text):
@@ -360,8 +175,7 @@ def timings(req: TtsRequest):
if not req.text.strip(): if not req.text.strip():
raise HTTPException(status_code=400, detail="text is required") raise HTTPException(status_code=400, detail="text is required")
voice = _resolve_voice(req) voice = _resolve_voice(req)
spoken_text, synth_voice = _prepare_synthesis_input(req.text, req.language, voice) total_ms = _estimate_total_ms(req, voice)
total_ms = _estimate_total_ms(req, synth_voice, spoken_text)
# Distribute total_ms across whitespace-split words proportional to # Distribute total_ms across whitespace-split words proportional to
# character count. Punctuation-only tokens are folded into the previous # character count. Punctuation-only tokens are folded into the previous
@@ -390,7 +204,7 @@ def timings(req: TtsRequest):
{ {
"text": req.text, "text": req.text,
"language": req.language, "language": req.language,
"voice": synth_voice, "voice": voice,
"words": out_words, "words": out_words,
"durationMs": total_ms, "durationMs": total_ms,
} }

View File

@@ -359,7 +359,7 @@ spec:
runAsUser: 1654 runAsUser: 1654
containers: containers:
- name: biblical-tts - name: biblical-tts
image: localhost/fc-biblical-tts:v20260506-hebrew-translit image: localhost/fc-biblical-tts:v1
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- containerPort: 10402 - containerPort: 10402
@@ -532,7 +532,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
containers: containers:
- name: web - name: web
image: localhost/fc-ttsreader-web:v20260506-phase6 image: localhost/fc-ttsreader-web:v20260506-47a88ae
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- containerPort: 5217 - containerPort: 5217
@@ -568,14 +568,6 @@ spec:
value: "http://ttsreader-kokoro.fc-ttsreader.svc.cluster.local.:8880" value: "http://ttsreader-kokoro.fc-ttsreader.svc.cluster.local.:8880"
- name: TtsReader__Kokoro__TimeoutSeconds - name: TtsReader__Kokoro__TimeoutSeconds
value: "120" value: "120"
- name: FlowerCore__Tts__BiblicalTts__Enabled
value: "true"
- name: FlowerCore__Tts__BiblicalTts__BaseUrl
value: "http://ttsreader-biblical.fc-ttsreader.svc.cluster.local.:10402"
- name: FlowerCore__Tts__BiblicalTts__TimeoutSeconds
value: "60"
- name: FlowerCore__Tts__BiblicalTts__DefaultLanguage
value: "grc"
- name: Speech__Alignment__Enabled - name: Speech__Alignment__Enabled
# Cluster-native faster-whisper (Lane F, 2026-04-25). The # Cluster-native faster-whisper (Lane F, 2026-04-25). The
# ttsreader-align deployment in this manifest wraps # ttsreader-align deployment in this manifest wraps
@@ -611,8 +603,6 @@ spec:
# the writable PVC mount. # the writable PVC mount.
- name: TtsReader__Preview__CacheDirectory - name: TtsReader__Preview__CacheDirectory
value: "/data/voice-previews" value: "/data/voice-previews"
- name: TtsReader__VoiceLibrary__ReferenceClip__Directory
value: "/data/voice-reference-clips"
# Sprint E XXL Phase 4γ — content-addressed CDN bundle dir for # Sprint E XXL Phase 4γ — content-addressed CDN bundle dir for
# POST /api/v1/render. Default "wwwroot/cdn" resolves under the # POST /api/v1/render. Default "wwwroot/cdn" resolves under the
# read-only app filesystem, so pin to the writable PVC mount # read-only app filesystem, so pin to the writable PVC mount

View File

@@ -1,47 +0,0 @@
# fc-updater — Update Center GitOps adoption
**Status:** adopted into `bluejay-infra` on 2026-05-06. The live ArgoCD
Application is `infra-fc-updater`, generated by the `bluejay-infra`
ApplicationSet with automated sync, `prune: true`, and `selfHeal: true`.
## Managed manifest set
`apps/fc-updater/fc-updater.yaml` manages:
- `Namespace/fc-updater`
- `PersistentVolumeClaim/updatecenter-data`
- `Deployment/updatecenter-web`
- `Service/updatecenter-web`
- `Certificate/updatecenter-web-tls`
- `Certificate/updatecenter-web-internal-tls`
- `IngressRoute/updatecenter-web`
- `IngressRoute/updatecenter-web-internal`
- `IngressRoute/updatecenter-web-public`
The Deployment intentionally sets `revisionHistoryLimit: 3` and
`strategy.type: Recreate`. The service is singleton + SQLite/local bundle
storage on `PersistentVolumeClaim/updatecenter-data`, pinned to
`rke2-server`.
## Runtime dependencies intentionally not stored here
These live Secrets are pre-existing runtime material and are not committed to
Git:
- `updater-bootstrap-auth`
- `updater-signing`
- `updater-webhooks`
- `cf-origin-flowercore-io`
Rotate the Cloudflare Origin Certificate through
`FlowerCore.Notes/docs/standards/code-signing-rotation-runbook.md`; the
shared origin cert must exist in every namespace that serves a
`*.flowercore.io` public IngressRoute.
## Verification
```powershell
kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n argocd get application infra-fc-updater
kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n fc-updater get deploy,svc,ingressroute,certificate,pvc
curl.exe -sk https://update.flowercore.io/api/v1/manifests/_schema
```

View File

@@ -1,269 +0,0 @@
# FlowerCore Update Center
# GitOps adoption of the live fc-updater namespace after PUB-1/PUB-3.
# Runtime credentials remain in existing K8s Secrets; do not store them here.
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-updater
labels:
app.kubernetes.io/part-of: flowercore
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: updatecenter-data
namespace: fc-updater
labels:
app.kubernetes.io/name: updatecenter-web
app.kubernetes.io/part-of: flowercore
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
volumeMode: Filesystem
resources:
requests:
# Sized for fleet bundle storage (LocalFsBundleStore.MaxTotalBytes
# soft cap at 25 GiB per project_uc_remaining_4_apps_signed_2026_05_06).
# Mike Bundle alone is ~5.1 GiB; cluster live capacity is already
# 20 GiB after a manual expand. PVCs cannot shrink, so git must track
# at least the live size to avoid the OutOfSync loop.
storage: 25Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: updatecenter-web
namespace: fc-updater
labels:
app: updatecenter-web
app.kubernetes.io/name: updatecenter-web
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
# SQLite + local bundle storage live on a single RWO PVC. Recreate avoids
# two pods overlapping the same write path during future image bumps.
type: Recreate
selector:
matchLabels:
app: updatecenter-web
template:
metadata:
labels:
app: updatecenter-web
spec:
nodeName: rke2-server
containers:
- name: web
image: localhost/fc-updater-web:v20260509-4162dca-authgate
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_URLS
value: http://+:8080
- name: FlowerCore__Updater__Database__Provider
value: sqlite
- name: FlowerCore__Updater__Database__ConnectionString
value: Data Source=/data/updatecenter.db
- name: FlowerCore__Updater__BundleStorage__LocalFs__RootDirectory
value: /data/bundles
- name: FlowerCore__Updater__PublicShares__RequirePublicVisibilityOnPublicHosts
value: "true"
- name: FlowerCore__Updater__PublicShares__Links__0__Code
value: 8f3c2a9e7d41
- name: FlowerCore__Updater__PublicShares__Links__0__AppId
value: flowercore.faith-ai-mike
- name: FlowerCore__Updater__PublicShares__Links__0__Channel
value: stable
- name: FlowerCore__Updater__PublicShares__Links__0__RuntimeId
value: win-x64
- name: FlowerCore__Updater__PublicShares__Links__0__DisplayName
value: Faith AI Mike Edition
- name: FlowerCore__Updater__PublicShares__Links__0__Headline
value: Faith AI Mike Edition
- name: FlowerCore__Updater__PublicShares__Links__0__Description
value: Private release link for Mike's Faith AI bundle.
- name: FlowerCore__Updater__Auth__Bootstrap__Enabled
value: "true"
- name: FlowerCore__Updater__Auth__Bootstrap__Username
valueFrom:
secretKeyRef:
name: updater-bootstrap-auth
key: username
- name: FlowerCore__Updater__Auth__Bootstrap__Password
valueFrom:
secretKeyRef:
name: updater-bootstrap-auth
key: password
- name: FlowerCore__Updater__Auth__Bootstrap__SigningKey
valueFrom:
secretKeyRef:
name: updater-bootstrap-auth
key: signing-key
- name: FlowerCore__Updater__Signing__AutoSignOnPublish
value: "true"
- name: FlowerCore__Updater__Signing__RequireSignatureOnPublish
value: "true"
- name: FlowerCore__Updater__Signing__PfxBase64
valueFrom:
secretKeyRef:
name: updater-signing
key: pfx-base64
- name: FlowerCore__Updater__Signing__PfxPassword
valueFrom:
secretKeyRef:
name: updater-signing
key: pfx-password
- name: FlowerCore__Updater__Signing__OpItemReference
value: op://FlowerCore/step-ca-codesign
- name: FlowerCore__Updater__Signing__TrustAnchorPath
value: /etc/flowercore-updater/signing/root-ca.pem
- name: FlowerCore__Updater__GitHub__Token
valueFrom:
secretKeyRef:
name: updater-webhooks
key: github-token
- name: FlowerCore__Updater__GitHub__WebhookSecret
valueFrom:
secretKeyRef:
name: updater-webhooks
key: github-webhook-secret
- name: FlowerCore__Updater__Gitea__Token
valueFrom:
secretKeyRef:
name: updater-webhooks
key: gitea-token
- name: FlowerCore__Updater__Gitea__WebhookSecret
valueFrom:
secretKeyRef:
name: updater-webhooks
key: gitea-webhook-secret
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 30
volumeMounts:
- name: data
mountPath: /data
- name: signing
mountPath: /etc/flowercore-updater/signing
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: updatecenter-data
- name: signing
secret:
secretName: updater-signing
items:
- key: root-ca.pem
path: root-ca.pem
---
apiVersion: v1
kind: Service
metadata:
name: updatecenter-web
namespace: fc-updater
labels:
app: updatecenter-web
app.kubernetes.io/name: updatecenter-web
app.kubernetes.io/part-of: flowercore
spec:
type: ClusterIP
selector:
app: updatecenter-web
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: updatecenter-web-tls
namespace: fc-updater
spec:
secretName: updatecenter-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- updatecenter.iamworkin.lan
- updates.iamworkin.lan
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: updatecenter-web-internal-tls
namespace: fc-updater
spec:
secretName: updatecenter-web-internal-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- updatecenter-internal.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: updatecenter-web
namespace: fc-updater
spec:
entryPoints:
- web
- websecure
routes:
- match: (Host(`updatecenter.iamworkin.lan`) || Host(`updates.iamworkin.lan`)) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
kind: Rule
services:
- name: updatecenter-web
port: 8080
tls:
secretName: updatecenter-web-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: updatecenter-web-internal
namespace: fc-updater
spec:
entryPoints:
- web
- websecure
routes:
- match: Host(`updatecenter-internal.iamworkin.lan`)
kind: Rule
services:
- name: updatecenter-web
port: 8080
tls:
secretName: updatecenter-web-internal-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: updatecenter-web-public
namespace: fc-updater
spec:
entryPoints:
- websecure
routes:
- match: (Host(`update.flowercore.io`) || Host(`updates.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
kind: Rule
services:
- name: updatecenter-web
port: 8080
tls:
secretName: cf-origin-flowercore-io

View File

@@ -1,7 +0,0 @@
# ArgoCD's bluejay-infra ApplicationSet uses a directory generator and does
# not require kustomization.yaml. Keep this anyway as the manifest inventory
# and for local `kubectl kustomize apps/fc-updater` previews.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- fc-updater.yaml

View File

@@ -1,11 +1,6 @@
# FlowerCore Tenant — retired flowercore.io placeholder. # FlowerCore Tenant — flowercore.io (main brand)
# # Public-facing placeholder landing page served by nginx
# Public flowercore.io/www.flowercore.io routing is now owned by # ArgoCD managed - BlueJay Lab
# apps/fc-landing/fc-landing.yaml. This tenant placeholder remains available
# only as an in-cluster service; do not create a duplicate public
# IngressRoute here because it competes with fc-landing and requires a
# namespace-local cf-origin-flowercore-io Secret.
# ArgoCD managed - BlueJay Lab
--- ---
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
@@ -15,9 +10,15 @@ metadata:
app.kubernetes.io/part-of: bluejay-infra app.kubernetes.io/part-of: bluejay-infra
flowercore.io/tenant: flowercore flowercore.io/tenant: flowercore
--- ---
# Landing page HTML # NOTE: The existing cf-origin-flowercore-io secret (covering *.flowercore.io)
apiVersion: v1 # must be copied into this namespace. It already exists in other namespaces.
kind: ConfigMap # Copy with: kubectl get secret cf-origin-flowercore-io -n fc-system -o yaml \
# | sed 's/namespace: .*/namespace: tenant-flowercore/' \
# | kubectl apply -f -
---
# Landing page HTML
apiVersion: v1
kind: ConfigMap
metadata: metadata:
name: flowercore-web-html name: flowercore-web-html
namespace: tenant-flowercore namespace: tenant-flowercore
@@ -307,6 +308,25 @@ spec:
selector: selector:
app: flowercore-web app: flowercore-web
ports: ports:
- port: 80 - port: 80
targetPort: 80 targetPort: 80
name: http name: http
---
# Traefik IngressRoute — public via Cloudflare
# Uses existing cf-origin-flowercore-io cert (must be copied to this namespace)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: flowercore-web
namespace: tenant-flowercore
spec:
entryPoints:
- websecure
routes:
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`)
kind: Rule
services:
- name: flowercore-web
port: 80
tls:
secretName: cf-origin-flowercore-io

View File

@@ -466,11 +466,11 @@ spec:
itemPath: vaults/IAmWorkin/items/Guacamole JSON Auth itemPath: vaults/IAmWorkin/items/Guacamole JSON Auth
--- ---
--- ---
# 1Password-backed credentials for Mac mini VNC access (Phase 1 <EFBFBD> 2026-04-28) # 1Password-backed credentials for Mac mini VNC access (Phase 1 2026-04-28)
# The operator mints Secret 'macmini-vnc-creds' with keys: username, password, VNC Password # The operator mints Secret 'macmini-vnc-creds' with keys: username, password, VNC Password
# Note: '1Password' field label 'VNC Password' -> K8s Secret key 'VNC Password' (space retained) # Note: '1Password' field label 'VNC Password' -> K8s Secret key 'VNC Password' (space retained)
# Guacamole VNC connection password is sourced from the 'VNC Password' field. # Guacamole VNC connection password is sourced from the 'VNC Password' field.
# Actual IP is 10.0.56.115 (INFRA VLAN) <EFBFBD> the 1P item 'IP' field is kept as backup reference. # Actual IP is 10.0.56.115 (INFRA VLAN) the 1P item 'IP' field is kept as backup reference.
apiVersion: onepassword.com/v1 apiVersion: onepassword.com/v1
kind: OnePasswordItem kind: OnePasswordItem
metadata: metadata:
@@ -481,7 +481,6 @@ metadata:
app.kubernetes.io/part-of: flowercore app.kubernetes.io/part-of: flowercore
spec: spec:
itemPath: vaults/IAmWorkin/items/Mac Mini itemPath: vaults/IAmWorkin/items/Mac Mini
---
# Blue Jay Branding Extension (CSS + translations) # Blue Jay Branding Extension (CSS + translations)
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap

View File

@@ -46,7 +46,7 @@ spec:
spec: spec:
containers: containers:
- name: intranet-web - name: intranet-web
image: localhost/fc-intranet-web:v20260508-brochure-w1 image: localhost/fc-intranet-web:v20260505-1108
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- containerPort: 5300 - containerPort: 5300

View File

@@ -241,12 +241,8 @@ spec:
kind: ClusterIssuer kind: ClusterIssuer
dnsNames: dnsNames:
- knowledge.iamworkin.lan - knowledge.iamworkin.lan
# step-ca ACME caps lifetime at 30d; requesting 90d silently capped duration: 2160h # 90d
# made renewBefore=cert-lifetime → perpetual renewal loop (10888+ CRs renewBefore: 720h # 30d
# in 18h on 2026-05-07). Match working 720h/240h pattern from other
# FC services.
duration: 720h # 30d (step-ca cap)
renewBefore: 240h # 10d
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute

View File

@@ -1,93 +0,0 @@
# =============================================================================
# ci1 - Windows Server 2025 KubeVirt VM (GitHub Actions Self-Hosted Runner)
# =============================================================================
# Boots from the sysprepped containerDisk template built by the Windows VM
# sysprep pipeline. See docs/infrastructure/windows-vm-sysprep-pipeline.md.
# Path A/B/C install history is preserved in git log only.
# =============================================================================
apiVersion: v1
kind: Namespace
metadata:
name: kubevirt-vms
labels:
app.kubernetes.io/part-of: kubevirt-stack
pod-security.kubernetes.io/enforce: privileged
---
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: ci1
namespace: kubevirt-vms
labels:
app: ci-runner
role: github-actions-runner
flowercore.io/managed-by: bluejay-infra
spec:
runStrategy: Always
template:
metadata:
labels:
app: ci-runner
role: github-actions-runner
kubevirt.io/vm: ci1
spec:
domain:
cpu:
cores: 8
sockets: 1
threads: 1
memory:
guest: 16Gi
resources:
requests:
memory: 16Gi
limits:
memory: 16Gi
clock:
utc: {}
timer:
hpet:
present: false
pit:
tickPolicy: delay
rtc:
tickPolicy: catchup
hyperv: {}
features:
acpi: {}
apic: {}
hyperv:
relaxed: {}
vapic: {}
spinlocks:
spinlocks: 8191
smm: {}
firmware:
bootloader:
efi:
secureBoot: false
devices:
tpm: {}
disks:
- name: rootdisk
disk:
bus: virtio
interfaces:
# Pod-network fallback for CI runner outbound traffic. Switch to
# prod-vlan57 once the bridge/NAD lane is ready for L2 access.
- name: default
masquerade: {}
model: virtio
machine:
type: q35
networks:
- name: default
pod: {}
volumes:
- name: rootdisk
containerDisk:
image: localhost/fc-win-server-2025:v1
imagePullPolicy: Never
terminationGracePeriodSeconds: 3600

View File

@@ -1,3 +0,0 @@
resources:
- ci1.yaml
- prod-vlan57-nad.yaml

View File

@@ -1,69 +0,0 @@
# =============================================================================
# NetworkAttachmentDefinition — PROD VLAN 57 bridge
# =============================================================================
# Purpose: makes KubeVirt VMs reachable on the PROD VLAN (10.0.57.0/24)
# alongside the existing pod network. Required for ci1 to bridge onto PROD
# (e.g. to provision/scrape edge1, edge2, kiosks, Pis on the same L2 segment).
#
# **DEPLOY GATE — Phase 1.5 host work required first**:
# On every RKE2 node (rke2-server, rke2-agent1, rke2-agent2):
# 1. Switch port (UniFi USL16LP) trunks VLAN 57 to the node — usually
# already true since BLUEJAY-WS reaches 10.0.57.x services. Verify
# with `ip link show enp86s0.57` after configuring sub-interface, OR
# `tcpdump -ni enp86s0 vlan 57` and ping a known PROD host.
# 2. Linux bridge `br-prod` enslaving `enp86s0.57` (VLAN sub-interface).
# NetworkManager profile examples in the runbook below.
# 3. Verify Multus DaemonSet `kube-multus-ds` is Ready on all nodes.
#
# Without those, applying this NAD has no effect except to register the CRD.
# A VM that requests this NAD with no bridge present will fail with:
# `error adding pod kubevirt-vms_ci1 to CNI network "prod-vlan57": failed to
# plumb VLAN: open /sys/class/net/br-prod/master: no such file or directory`
#
# Configuration notes:
# - cniVersion 0.3.1 to match Multus daemon-config.json
# - mtu 1500 (matches enp86s0 default; bump if jumbo frames configured)
# - bridge name `br-prod` is convention; if Puppet picks a different name
# (e.g. `br57`, `br-vlan57`), edit BOTH this NAD and the ci1.yaml
# interface block. Keep them in sync.
# - vlan: 0 because the host bridge already strips VLAN tag (br-prod sits
# on top of `enp86s0.57`). If we instead used a VLAN-aware bridge with
# trunk port, set vlan: 57 here. Current convention is VLAN-stripped at
# the sub-interface, so the bridge passes untagged frames.
#
# Apply:
# kubectl --kubeconfig $env:USERPROFILE\.kube\rke2.yaml apply -f apps/kubevirt-vms/prod-vlan57-nad.yaml
#
# Then update ci1.yaml networks: stanza to:
# - name: prod-net
# multus:
# networkName: kubevirt-vms/prod-vlan57
# and the interface block from `masquerade` to `bridge`.
# =============================================================================
---
# Namespace must exist already (created by ci1.yaml's first document).
# This file imports a NAD into that same namespace.
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: prod-vlan57
namespace: kubevirt-vms
annotations:
bluejay.iamworkin.lan/host-bridge: "br-prod (enslaves enp86s0.57)"
bluejay.iamworkin.lan/cidr: "10.0.57.0/24"
bluejay.iamworkin.lan/gateway: "10.0.57.1"
bluejay.iamworkin.lan/dns: "10.0.56.1 (pfSense Unbound)"
spec:
config: |
{
"cniVersion": "0.3.1",
"name": "prod-vlan57",
"type": "bridge",
"bridge": "br-prod",
"ipam": {},
"mtu": 1500,
"vlan": 0,
"promiscMode": true,
"preserveDefaultVlan": false
}

View File

@@ -1,99 +0,0 @@
# =============================================================================
# Windows Server 2025 ISO — Static NFS PV (Path B for SATA-CDROM timeout)
# =============================================================================
# Purpose: Mount the ISO from Synology NAS via NFS instead of from a Longhorn-
# backed Filesystem PVC.
#
# Why: SATA-CDROM emulation reading from a Longhorn-backed Filesystem PVC is
# too slow for OVMF's boot read window — the DVD-ROM enumeration times out
# before the bootloader can be read. Symptom on the serial console:
# BdsDxe: failed to start Boot0001 "UEFI QEMU DVD-ROM QM00001 " from ...
# BdsDxe: failed to start Boot0001 ... Time out
# BdsDxe: No bootable option or device was found
# Diagnosis confirmed the ISO content is a perfectly valid bootable ISO9660
# image — the bug is in the timing path between OVMF and Longhorn-backed
# storage, not in the ISO itself.
#
# Block-mode PVC was tried (`volumeMode: Block` via DataVolume) and would
# likely fix the timing, but CDI v1.65.0's upload-target pod cannot open the
# block device due to runAsUser:107 + capabilities.drop:[ALL] and we got:
# blockdev: cannot open /dev/cdi-block-volume: Permission denied
#
# NFS-mounted ISO bypasses both issues: no Longhorn slowness, no CDI upload
# pod permission concerns. The ISO is read directly from the NAS over a
# native NFSv4.1 mount that QEMU's SATA emulator can read at full LAN speed.
#
# Layout on Synology:
# /volume1/ISOs/ (existing export, RKE2 ACL)
# en-us_windows_server_2025_updated_march_2026_x64_dvd_8e06425a.iso
# win2025-iso-disk/ (new subdir, 2026-05-08)
# disk.img -> hardlink to ../en-us_windows_server_2025_..._8e06425a.iso
#
# KubeVirt's launcher pod expects a PVC mounted at
# /var/run/kubevirt-private/vmi-disks/<diskName>/disk.img — by mounting the
# `win2025-iso-disk/` subdir as the NFS PV root, `disk.img` lives at the PV's
# root and KubeVirt's CDROM emulator finds it without any path manipulation.
#
# A symlink would NOT work for sub-path NFS mounts (the relative target
# `../...iso` falls outside the sub-mount root). A hardlink works because it
# references the same inode regardless of mount point.
#
# Memory references:
# - feedback_synology_nfs_volume1_kubernetes_export_scoped (Synology export
# scoping pattern — but /volume1/ISOs export, unlike /volume1/kubernetes,
# does support sub-path mounts because Synology NFS is configured with
# pseudo-fs in NFSv4.1)
# - feedback_kubevirt_iso_first_install_bootorder_and_runstrategy (boot
# order / runStrategy gotchas, separate from the storage timing issue)
#
# Validation (2026-05-08, from rke2-server / rke2-agent1 / rke2-agent2):
# mount -t nfs -o nfsvers=4.1,ro 10.0.58.3:/volume1/ISOs/win2025-iso-disk /tmp/m
# file /tmp/m/disk.img
# -> ISO 9660 CD-ROM filesystem data 'SSS_X64FRE_EN-US_DV9' (bootable)
# All 3 RKE2 nodes can mount and read.
# =============================================================================
apiVersion: v1
kind: PersistentVolume
metadata:
name: windows-server-2025-iso-nfs
labels:
flowercore.io/iso: windows-server-2025
flowercore.io/managed-by: bluejay-infra
spec:
capacity:
storage: 8Gi
accessModes:
- ReadOnlyMany
volumeMode: Filesystem
persistentVolumeReclaimPolicy: Retain
storageClassName: "" # static, no provisioner
mountOptions:
- nfsvers=4.1
- ro
- hard
- timeo=600
- retrans=3
nfs:
server: 10.0.58.3 # BlueJayNAS Synology DS1621+ on HOME VLAN 58
path: /volume1/ISOs/win2025-iso-disk
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: windows-server-2025-iso-nfs
namespace: kubevirt-vms
labels:
app: ci-runner
flowercore.io/managed-by: bluejay-infra
spec:
accessModes:
- ReadOnlyMany
volumeMode: Filesystem
resources:
requests:
storage: 8Gi
storageClassName: ""
volumeName: windows-server-2025-iso-nfs

File diff suppressed because one or more lines are too long

View File

@@ -1,297 +0,0 @@
# =============================================================================
# Multus CNI — Meta-CNI for multi-network attachment to pods/VMs
# =============================================================================
# Purpose: enable KubeVirt VMs (and any future workload) to attach additional
# network interfaces beyond the default Calico-managed pod network. Required
# for ci1 (Windows Server 2025 KubeVirt VM) to bridge onto PROD VLAN 57.
#
# Source: upstream k8snetworkplumbingwg/multus-cni v4.2.2
# https://github.com/k8snetworkplumbingwg/multus-cni/blob/v4.2.2/deployments/multus-daemonset-thick.yml
#
# Inlined verbatim (with project header + version pin annotation) for
# reproducibility and air-gap safety. Bumping versions = edit this file +
# git push. ArgoCD picks up via the bluejay-infra ApplicationSet
# (apps/* directory generator on main).
#
# Why thick plugin (not thin):
# - Thick = daemon + thin shim binary; daemon handles NAD watch + CRD reads
# centrally so each pod's CNI ADD doesn't hit the K8s API server. Better
# for clusters with many NAD-using pods.
# - Thin = each CNI ADD process directly contacts K8s API. Simpler but
# scales worse and has more failure modes.
# - KubeVirt + multi-VM workload pattern fits thick perfectly.
#
# Cluster context (verified 2026-05-08):
# - RKE2 v1.34.5 on 3 nodes (rke2-server, rke2-agent1, rke2-agent2)
# - Calico CNI (Tigera-managed) at /etc/cni/net.d + /opt/cni/bin (default)
# - openSUSE Leap 16, kernel 6.12, containerd 2.1.5
# - host bridge for PROD VLAN 57 = `br-prod` (PUPPET HOST WORK — see Phase 1.5
# in docs/infrastructure/windows-server-build-runner-plan.md)
#
# Version pin: snapshot-thick → pinning to v4.2.2 release tag at deploy time
# would require a private mirror of the image. Upstream `snapshot-thick` tag
# is updated on every release, so for now we trust upstream + Calico's
# established pattern. Pin to a specific SHA256 once we mirror to Gitea OCI.
#
# Apply (once committed to bluejay-infra main, ApplicationSet auto-syncs):
# git add apps/multus/multus.yaml && git commit && git push origin main
# # ArgoCD `infra-multus` Application appears within 3 min via ApplicationSet
#
# Verify:
# kubectl -n kube-system get ds kube-multus-ds
# kubectl -n kube-system rollout status ds kube-multus-ds
# kubectl get crd network-attachment-definitions.k8s.cni.cncf.io
# =============================================================================
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: network-attachment-definitions.k8s.cni.cncf.io
annotations:
bluejay.iamworkin.lan/source: "k8snetworkplumbingwg/multus-cni v4.2.2"
spec:
group: k8s.cni.cncf.io
scope: Namespaced
names:
plural: network-attachment-definitions
singular: network-attachment-definition
kind: NetworkAttachmentDefinition
shortNames:
- net-attach-def
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
description: 'NetworkAttachmentDefinition is a CRD schema specified by the Network Plumbing
Working Group to express the intent for attaching pods to one or more logical or physical
networks. More information available at: https://github.com/k8snetworkplumbingwg/multi-net-spec'
type: object
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
description: 'NetworkAttachmentDefinition spec defines the desired state of a network attachment'
type: object
properties:
config:
description: 'NetworkAttachmentDefinition config is a JSON-formatted CNI configuration'
type: string
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: multus
rules:
- apiGroups: ["k8s.cni.cncf.io"]
resources:
- '*'
verbs:
- '*'
- apiGroups:
- ""
resources:
- pods
- pods/status
verbs:
- get
- list
- update
- watch
- apiGroups:
- ""
- events.k8s.io
resources:
- events
verbs:
- create
- patch
- update
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: multus
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: multus
subjects:
- kind: ServiceAccount
name: multus
namespace: kube-system
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: multus
namespace: kube-system
---
kind: ConfigMap
apiVersion: v1
metadata:
name: multus-daemon-config
namespace: kube-system
labels:
tier: node
app: multus
data:
daemon-config.json: |
{
"chrootDir": "/hostroot",
"cniVersion": "0.3.1",
"logLevel": "verbose",
"logToStderr": true,
"cniConfigDir": "/host/etc/cni/net.d",
"multusAutoconfigDir": "/host/etc/cni/net.d",
"multusConfigFile": "auto",
"socketDir": "/host/run/multus/"
}
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: kube-multus-ds
namespace: kube-system
labels:
tier: node
app: multus
name: multus
spec:
selector:
matchLabels:
name: multus
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
tier: node
app: multus
name: multus
spec:
hostNetwork: true
hostPID: true
tolerations:
- operator: Exists
effect: NoSchedule
- operator: Exists
effect: NoExecute
serviceAccountName: multus
containers:
- name: kube-multus
image: ghcr.io/k8snetworkplumbingwg/multus-cni:snapshot-thick
command: [ "/usr/src/multus-cni/bin/multus-daemon" ]
# 2026-05-11: upstream default of 50Mi memory limit OOM-cascades when
# an operator-owned namespace accumulates >100 pending pods retrying
# CNI ADD. RemoteDesktop emitted 219 orphan rd-browser-only pods
# (missing OwnerReferences), kubelet's CNI ADD avalanche pushed multus
# over 50Mi, OOMKilled, restarted with even bigger backlog → loop.
# 21h cluster outage. See FlowerCore.Notes:
# feedback_multus_50mi_limit_oom_orphan_pod_avalanche.md
# 1Gi limit / 512Mi request comfortably handles a 200+ pod CNI
# catchup burst on 64GB nodes (nodes are <25% used in steady-state).
# Drop back toward 256Mi only after MultusMemoryPressure alert
# proves steady-state working set sits well below 200Mi.
resources:
requests:
cpu: "100m"
memory: "512Mi"
limits:
cpu: "100m"
memory: "1Gi"
securityContext:
privileged: true
terminationMessagePolicy: FallbackToLogsOnError
volumeMounts:
- name: cni
mountPath: /host/etc/cni/net.d
# multus-daemon expects that cnibin path must be identical between pod and container host.
# e.g. if the cni bin is in '/opt/cni/bin' on the container host side, then it should be mount to '/opt/cni/bin' in multus-daemon,
# not to any other directory, like '/opt/bin' or '/usr/bin'.
- name: cnibin
mountPath: /opt/cni/bin
- name: host-run
mountPath: /host/run
- name: host-var-lib-cni-multus
mountPath: /var/lib/cni/multus
- name: host-var-lib-kubelet
mountPath: /var/lib/kubelet
mountPropagation: HostToContainer
- name: host-run-k8s-cni-cncf-io
mountPath: /run/k8s.cni.cncf.io
- name: host-run-netns
mountPath: /run/netns
mountPropagation: HostToContainer
- name: multus-daemon-config
mountPath: /etc/cni/net.d/multus.d
readOnly: true
- name: hostroot
mountPath: /hostroot
mountPropagation: HostToContainer
- mountPath: /etc/cni/multus/net.d
name: multus-conf-dir
env:
- name: MULTUS_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
initContainers:
- name: install-multus-binary
image: ghcr.io/k8snetworkplumbingwg/multus-cni:snapshot-thick
command:
- "sh"
- "-c"
- "cp /usr/src/multus-cni/bin/multus-shim /host/opt/cni/bin/multus-shim && cp /usr/src/multus-cni/bin/passthru /host/opt/cni/bin/passthru"
resources:
requests:
cpu: "10m"
memory: "15Mi"
securityContext:
privileged: true
terminationMessagePolicy: FallbackToLogsOnError
volumeMounts:
- name: cnibin
mountPath: /host/opt/cni/bin
mountPropagation: Bidirectional
terminationGracePeriodSeconds: 10
volumes:
- name: cni
hostPath:
path: /etc/cni/net.d
- name: cnibin
hostPath:
path: /opt/cni/bin
- name: hostroot
hostPath:
path: /
- name: multus-daemon-config
configMap:
name: multus-daemon-config
items:
- key: daemon-config.json
path: daemon-config.json
- name: host-run
hostPath:
path: /run
- name: host-var-lib-cni-multus
hostPath:
path: /var/lib/cni/multus
- name: host-var-lib-kubelet
hostPath:
path: /var/lib/kubelet
- name: host-run-k8s-cni-cncf-io
hostPath:
path: /run/k8s.cni.cncf.io
- name: host-run-netns
hostPath:
path: /run/netns/
- name: multus-conf-dir
hostPath:
path: /etc/cni/multus/net.d

View File

@@ -1,210 +0,0 @@
# Selenium Grid NetworkPolicy.
#
# Captured into bluejay-infra 2026-05-07 during the regroup audit. This
# NetworkPolicy was previously applied via `kubectl apply` directly to
# the cluster with no source-of-truth anywhere — a fresh cluster rebuild
# would have lost all of it (including the Selenium Grid → Traefik VIP
# allow rule for AAT runs against `*.iamworkin.lan` services).
#
# The Selenium Grid Deployment + Services themselves are still managed
# outside ArgoCD (deployed via raw kubectl from the original Selenium
# Grid bring-up). Migrating those into bluejay-infra is a separate lane —
# this commit only restores GitOps repeatability for the NetworkPolicy.
#
# Rules captured from the live cluster's `kubectl get netpol -n selenium
# selenium-netpol -o yaml` on 2026-05-07. Originally applied 2026-03-15
# (from `metadata.creationTimestamp` before the field was stripped).
#
# Allows:
# - Egress: CoreDNS, intra-namespace pod-to-pod (4442/4443/4444/5555),
# Traefik VIP for `*.iamworkin.lan` AAT runs, all FC namespaces on
# standard FC service ports (5100/5200/5300/5400/8080), pod CIDR
# (10.42.0.0/16) + service CIDR (10.43.0.0/16) for the same ports,
# LAN gateway range (10.0.56.0/24) for HTTPS, edge2 CUPS print
# (10.0.57.16:5200), public internet 80/443 (excluding RFC1918), and
# fc-signage:5190 for the signage AAT lane.
# - Ingress: Traefik (4444 + 8089 ACME-solver-style), intra-pod,
# telephony / gitea / fc-system / fc-signage namespaces on 4444.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: selenium-netpol
namespace: selenium
labels:
app.kubernetes.io/part-of: selenium
app.kubernetes.io/component: isolation
spec:
egress:
- ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
- ports:
- port: 4442
protocol: TCP
- port: 4443
protocol: TCP
- port: 4444
protocol: TCP
- port: 5555
protocol: TCP
to:
- podSelector: {}
- ports:
- port: 443
protocol: TCP
- port: 80
protocol: TCP
to:
- ipBlock:
cidr: 10.0.56.200/32
- ports:
- port: 443
protocol: TCP
- port: 80
protocol: TCP
- port: 5200
protocol: TCP
- port: 5300
protocol: TCP
- port: 5400
protocol: TCP
- port: 5100
protocol: TCP
- port: 8080
protocol: TCP
to:
- namespaceSelector: {}
- ports:
- port: 443
protocol: TCP
- port: 80
protocol: TCP
- port: 8443
protocol: TCP
- port: 8080
protocol: TCP
- port: 5200
protocol: TCP
- port: 5300
protocol: TCP
- port: 5400
protocol: TCP
- port: 5100
protocol: TCP
to:
- ipBlock:
cidr: 10.43.0.0/16
- ports:
- port: 443
protocol: TCP
- port: 80
protocol: TCP
- port: 8443
protocol: TCP
- port: 8080
protocol: TCP
- port: 5200
protocol: TCP
- port: 5300
protocol: TCP
- port: 5400
protocol: TCP
- port: 5100
protocol: TCP
to:
- ipBlock:
cidr: 10.42.0.0/16
- ports:
- port: 443
protocol: TCP
- port: 80
protocol: TCP
- port: 8443
protocol: TCP
to:
- ipBlock:
cidr: 10.0.56.0/24
- ports:
- port: 5200
protocol: TCP
to:
- ipBlock:
cidr: 10.0.57.16/32
- ports:
- port: 80
protocol: TCP
- port: 443
protocol: TCP
to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 172.16.0.0/12
- 192.168.0.0/16
- ports:
- port: 5190
protocol: TCP
to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-signage
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
ports:
- port: 4444
protocol: TCP
- port: 8089
protocol: TCP
- from:
- podSelector: {}
ports:
- port: 4442
protocol: TCP
- port: 4443
protocol: TCP
- port: 4444
protocol: TCP
- port: 5555
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: telephony
ports:
- port: 4444
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: gitea
ports:
- port: 4444
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-system
ports:
- port: 4444
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-signage
ports:
- port: 4444
protocol: TCP
podSelector: {}
policyTypes:
- Ingress
- Egress

View File

@@ -127,13 +127,10 @@ spec:
initContainers: initContainers:
- name: fix-data-perms - name: fix-data-perms
image: busybox:latest image: busybox:latest
# Must run as root to chown the hostPath /tmp/tts-audio that may be # Also chown /shared-tts (hostPath /tmp/tts-audio) so the non-root
# root-owned after node reboot. Pod-level runAsNonRoot:true would # app user (uid 1654) can write Piper .sln16 files that Asterisk
# otherwise inherit and chown would fail with EPERM (see Notes memory # reads at /var/lib/asterisk/sounds/tts. World-readable (755) is
# feedback_hostpath_initcontainer_chown_perms). # fine — Asterisk runs as a different uid in the other pod.
securityContext:
runAsUser: 0
runAsNonRoot: false
command: ["sh", "-c", "chown -R 1654:1654 /data && chown 1654:1654 /shared-tts && chmod 0755 /shared-tts"] command: ["sh", "-c", "chown -R 1654:1654 /data && chown 1654:1654 /shared-tts && chmod 0755 /shared-tts"]
volumeMounts: volumeMounts:
- name: telephony-data - name: telephony-data

View File

@@ -1,60 +0,0 @@
# FlowerCore.WorldBuilder
ArgoCD-managed manifest for FlowerCore.WorldBuilder.Web — comic / storyboard
authoring service that drives ComfyUI for panel image generation and
QuestPDF for letter / A4 export.
Source: `D:\git\FlowerCore\FlowerCore.WorldBuilder` (master)
## Deployment order
1. **DNS preflight**`worldbuilder.iamworkin.lan -> 10.0.56.200` MUST exist
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:
```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/
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"
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`,
create World → Character → Storyboard → ExportJob, confirm artifact
downloads. ComfyUI lives on BLUEJAY-WS at `http://10.0.56.20:8188`.
## Health probes
- `startupProbe` + `readinessProbe`: `httpGet /healthz` (registered explicitly
in Program.cs — anonymous, no DB or OpenAPI dependency).
- `livenessProbe`: `tcpSocket` as a cheap fallback.
Memory: `feedback_k8s_probes_must_not_hit_openapi`,
`feedback_k8s_probes_behind_auth_middleware`.
## Storage
- Longhorn RWO PVC `worldbuilder-data` (5Gi) mounted at `/data`. SQLite DB
lives at `/data/worldbuilder.db`, generated images under `/data/gallery/`,
PDF/PNG exports under `/data/exports/`.
- DataProtection keys persist to the same SQLite via
`AddFlowerCoreDataProtection<WorldBuilderDbContext>` — explicit migration
`20260429133417_Initial` already creates `fc_dp_keys`.
Memory: `feedback_dataprotection_keys_persist_to_app_dbcontext`,
`feedback_intranet_dataprotection_table_must_have_explicit_migration`.
## Image generation backend
`FlowerCore:WorldBuilder:ImageGeneration:BaseUrl=http://10.0.56.20:8188` —
ComfyUI runs on BLUEJAY-WS Windows (R9700 / gfx1201 / ROCm 7.2.1). Pod reaches
the workstation directly across the 10.0.56.0/24 VLAN (no Podman-style host-
filter issues — K8s pods route via Calico, which is L3-routed across the
VLAN).

View File

@@ -1,213 +0,0 @@
# FlowerCore.WorldBuilder — comic / storyboard authoring service.
#
# Deployment + Service + PVC + Certificate + IngressRoute. ArgoCD-managed
# end-to-end. See apps/worldbuilder/README.md for the per-deploy runbook.
#
# 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"
# done
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-worldbuilder
labels:
app.kubernetes.io/part-of: flowercore
---
# SQLite DB + generated image gallery + PDF/PNG exports.
# Longhorn RWO — single replica with `Recreate` rollout strategy keeps it safe.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: worldbuilder-data
namespace: fc-worldbuilder
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: worldbuilder-web
namespace: fc-worldbuilder
labels:
app.kubernetes.io/name: worldbuilder-web
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
# RWO PVC + single replica. Recreate avoids multi-attach overlap.
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: worldbuilder-web
template:
metadata:
labels:
app.kubernetes.io/name: worldbuilder-web
app.kubernetes.io/part-of: flowercore
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics/prometheus"
spec:
securityContext:
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
# Bump tag for each rebuild. Initial deploy: v202605062048
image: localhost/fc-worldbuilder:v202605062048
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: DOTNET_RUNNING_IN_CONTAINER
value: "true"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
# SQLite path overrides (default appsettings uses relative paths).
- name: ConnectionStrings__DefaultConnection
value: "Data Source=/data/worldbuilder.db"
- name: FlowerCore__Database__Provider
value: "Sqlite"
- name: FlowerCore__Database__ConnectionStrings__Sqlite
value: "Data Source=/data/worldbuilder.db"
# Generated image gallery + exports persist on /data.
- name: FlowerCore__WorldBuilder__ImageStore__RootPath
value: "/data/gallery"
- name: FlowerCore__WorldBuilder__Export__RootPath
value: "/data/exports"
# ComfyUI on BLUEJAY-WS (R9700 / gfx1201 / ROCm 7.2.1).
- name: FlowerCore__WorldBuilder__ImageGeneration__BaseUrl
value: "http://10.0.56.20:8188"
- name: FlowerCore__WorldBuilder__ImageGeneration__ClientMode
value: "comfyui"
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
# Server + SignalR + a single ComfyUI poller uses ~5m, so 25m is
# generous. Re-evaluate if active rendering/export workers ever
# push past the limit.
requests:
cpu: 25m
memory: 256Mi
limits:
cpu: 1000m
memory: 768Mi
# /healthz is registered explicitly in Program.cs (anonymous, no DB
# or OpenAPI dependency). Liveness uses tcpSocket as a cheap fallback
# in case future middleware changes accidentally gate /healthz.
# Memory: feedback_k8s_probes_must_not_hit_openapi,
# feedback_k8s_probes_behind_auth_middleware.
startupProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 8080
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: worldbuilder-data
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: worldbuilder-web
namespace: fc-worldbuilder
labels:
app.kubernetes.io/name: worldbuilder-web
app.kubernetes.io/part-of: flowercore
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: worldbuilder-web
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: worldbuilder-web-tls
namespace: fc-worldbuilder
spec:
secretName: worldbuilder-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- worldbuilder.iamworkin.lan
# step-ca ACME provisioner caps lifetime at 30d. Requesting 90d
# silently capped to 30d, making renewBefore 720h (30d) equal to the
# actual cert lifetime — triggered a perpetual renewal loop that
# generated 2365+ CertificateRequest objects in 18h. Match the working
# 720h/240h pattern used by every other FC service cert.
duration: 720h # 30d (step-ca cap)
renewBefore: 240h # 10d
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: worldbuilder-web
namespace: fc-worldbuilder
spec:
entryPoints:
- websecure
routes:
- match: Host(`worldbuilder.iamworkin.lan`)
kind: Rule
services:
- name: worldbuilder-web
port: 80
tls:
secretName: worldbuilder-web-tls

View File

@@ -1,416 +0,0 @@
#!/usr/bin/env python3
"""Generate and optionally apply FlowerCore Authentik OIDC client assets.
Dry-run is the default. Live Authentik mutations require --apply plus an
AUTHENTIK_TOKEN bearer token and an operator-provided client secret JSON file.
The script never prints client_secret values.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any
CLAIMS = (
"fc:roles",
"fc:tenant",
"fc:svc",
"fc:scope",
"fc:mfa",
"flowercore_actor_id",
)
BUILTIN_SCOPE_MAPPINGS = (
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-profile",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-offline_access",
)
FLOW_CONTRACT = {
"response_type": "code",
"grant_types": ["authorization_code", "refresh_token"],
"offline_access_required": True,
}
@dataclass(frozen=True)
class ServiceSpec:
slug: str
namespace: str
display_name: str
host: str
@property
def client_id(self) -> str:
return self.slug
@property
def provider_name(self) -> str:
return f"FlowerCore {self.display_name} OIDC"
@property
def application_name(self) -> str:
return f"FlowerCore {self.display_name}"
@property
def issuer_url(self) -> str:
return f"https://id.iamworkin.lan/application/o/{self.slug}/"
@property
def onepassword_item_path(self) -> str:
return f"IAmWorkin/items/{self.slug}-oidc-client"
SERVICE_SPECS = (
ServiceSpec("library", "fc-library", "Library", "library.iamworkin.lan"),
ServiceSpec("retail", "fc-retail", "Retail", "retail.iamworkin.lan"),
ServiceSpec("telephony", "telephony", "Telephony", "telephony.iamworkin.lan"),
ServiceSpec("knowledge", "knowledge", "Knowledge", "knowledge.iamworkin.lan"),
ServiceSpec("llmbridge", "fc-llm-bridge", "LlmBridge", "fc-llm-bridge.iamworkin.lan"),
ServiceSpec("mysql", "fc-mysql", "MySQL", "mysql.iamworkin.lan"),
ServiceSpec("php", "fc-php", "PHP", "php.iamworkin.lan"),
ServiceSpec("signage", "fc-signage", "Signage", "signage.iamworkin.lan"),
ServiceSpec("media", "fc-media", "Media", "media.iamworkin.lan"),
ServiceSpec("dms", "fc-dms", "DMS", "dms.iamworkin.lan"),
ServiceSpec("pimanager", "fc-pimanager", "PiManager", "pimanager.iamworkin.lan"),
ServiceSpec("distribution", "fc-distribution", "Distribution", "distribution.iamworkin.lan"),
ServiceSpec("dns", "fc-dns", "DNS", "dns.iamworkin.lan"),
ServiceSpec("print", "fc-print", "Print", "print.iamworkin.lan"),
ServiceSpec("aistation", "fc-aistation", "AiStation", "aistation.iamworkin.lan"),
ServiceSpec("irc", "irc", "IRC", "irc.iamworkin.lan"),
ServiceSpec("ttsreader", "fc-ttsreader", "TtsReader", "ttsreader.iamworkin.lan"),
ServiceSpec("chat", "fc-chat", "Chat", "chat.iamworkin.lan"),
ServiceSpec("intranet", "intranet", "Intranet", "intranet.iamworkin.lan"),
ServiceSpec("remotedesktop", "fc-desktop", "RemoteDesktop", "remotedesktop.iamworkin.lan"),
ServiceSpec("provisioning", "fc-provisioning", "Provisioning", "provisioning.iamworkin.lan"),
ServiceSpec("scoreboards", "fc-scoreboard", "Scoreboards", "scoreboards.iamworkin.lan"),
ServiceSpec("mndot", "fc-mndot", "MnDOT", "mndot.iamworkin.lan"),
ServiceSpec("kiosk", "fc-system", "Kiosk", "kiosk.iamworkin.lan"),
ServiceSpec("mike-bundle", "fc-mike-bundle", "Mike Bundle", "mike-bundle.iamworkin.lan"),
ServiceSpec("messageboard", "fc-messageboard", "MessageBoard", "messageboard.iamworkin.lan"),
ServiceSpec("menuboard", "fc-menuboard", "MenuBoard", "menuboard.iamworkin.lan"),
ServiceSpec("presentations", "fc-presentations", "Presentations", "presentations.iamworkin.lan"),
ServiceSpec("segmentdisplay", "fc-segmentdisplay", "SegmentDisplay", "segmentdisplay.iamworkin.lan"),
ServiceSpec("signalcontrol", "fc-signalcontrol", "SignalControl", "signalcontrol.iamworkin.lan"),
ServiceSpec("worldbuilder", "fc-worldbuilder", "WorldBuilder", "worldbuilder.iamworkin.lan"),
ServiceSpec("audit", "fc-audit", "Audit", "audit.iamworkin.lan"),
ServiceSpec("licensing", "fc-licensing", "Licensing", "licensing.iamworkin.lan"),
)
def scope_mapping_payloads(service: ServiceSpec) -> list[dict[str, str]]:
managed_prefix = f"flowercore.io/authentik/oidc/{service.slug}"
return [
{
"managed": f"{managed_prefix}/fc-roles",
"name": f"FlowerCore {service.slug} fc:roles",
"scope_name": "flowercore",
"description": "FlowerCore role claim from Authentik group memberships.",
"expression": (
"groups = [group.name for group in request.user.ak_groups.all()]\n"
"return {'fc:roles': ','.join(groups)}"
),
},
{
"managed": f"{managed_prefix}/fc-tenant",
"name": f"FlowerCore {service.slug} fc:tenant",
"scope_name": "flowercore",
"description": "FlowerCore tenant claim from group attribute fc_tenant_id.",
"expression": (
"for group in request.user.ak_groups.all():\n"
" tenant_id = group.attributes.get('fc_tenant_id')\n"
" if tenant_id:\n"
" return {'fc:tenant': tenant_id}\n"
"return {'fc:tenant': 'default'}"
),
},
{
"managed": f"{managed_prefix}/fc-svc",
"name": f"FlowerCore {service.slug} fc:svc",
"scope_name": "flowercore",
"description": "FlowerCore service slug claim.",
"expression": f"return {{'fc:svc': '{service.slug}'}}",
},
{
"managed": f"{managed_prefix}/fc-scope",
"name": f"FlowerCore {service.slug} fc:scope",
"scope_name": "flowercore",
"description": "FlowerCore service permission scope claim.",
"expression": f"return {{'fc:scope': 'flowercore:{service.slug}'}}",
},
{
"managed": f"{managed_prefix}/fc-mfa",
"name": f"FlowerCore {service.slug} fc:mfa",
"scope_name": "flowercore",
"description": "FlowerCore MFA satisfied session claim.",
"expression": (
"mfa_stage = request.session.get('authentik/stages/authenticator_validate')\n"
"return {'fc:mfa': bool(mfa_stage)}"
),
},
{
"managed": f"{managed_prefix}/flowercore-actor-id",
"name": f"FlowerCore {service.slug} flowercore_actor_id",
"scope_name": "flowercore",
"description": "FlowerCore audit actor alias for the Authentik user id.",
"expression": "return {'flowercore_actor_id': str(request.user.uid)}",
},
]
def provider_payload(
service: ServiceSpec,
args: argparse.Namespace,
mapping_ids: list[str],
client_secret: str,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"name": service.provider_name,
"authorization_flow": args.authorization_flow,
"invalidation_flow": args.invalidation_flow,
"property_mappings": mapping_ids,
"client_type": "confidential",
"client_id": service.client_id,
"client_secret": client_secret,
"access_code_validity": "minutes=1",
"access_token_validity": "hours=1",
"refresh_token_validity": "days=30",
"include_claims_in_id_token": True,
"redirect_uris": [
{"matching_mode": "strict", "url": f"https://{service.host}/signin-oidc"},
{"matching_mode": "strict", "url": f"https://{service.host}/signout-callback-oidc"},
],
"sub_mode": "hashed_user_id",
"issuer_mode": "per_provider",
}
if args.authentication_flow:
payload["authentication_flow"] = args.authentication_flow
if args.signing_key:
payload["signing_key"] = args.signing_key
return payload
def application_payload(service: ServiceSpec, provider_pk: int | str | None) -> dict[str, Any]:
return {
"name": service.application_name,
"slug": service.slug,
"provider": provider_pk,
"open_in_new_tab": True,
"meta_launch_url": f"https://{service.host}/",
"meta_description": f"FlowerCore {service.display_name} OIDC client",
"meta_publisher": "FlowerCore",
"policy_engine_mode": "all",
"group": "FlowerCore",
}
def load_client_secrets(path: str | None) -> dict[str, str]:
if not path:
return {}
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("client secret file must be a JSON object keyed by service slug")
return {str(key): str(value) for key, value in data.items()}
def redact(value: Any) -> Any:
if isinstance(value, dict):
return {
key: "<redacted>" if key == "client_secret" else redact(child)
for key, child in value.items()
}
if isinstance(value, list):
return [redact(child) for child in value]
return value
class AuthentikClient:
def __init__(self, base_url: str, token: str) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
url = f"{self.base_url}/api/v3/{path.lstrip('/')}"
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {self.token}",
}
if payload is not None:
headers["Content-Type"] = "application/json"
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=30) as response:
text = response.read().decode("utf-8")
except urllib.error.HTTPError as error:
error_text = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} {path} failed with HTTP {error.code}: {error_text}") from error
if not text:
return None
return json.loads(text)
def first_result(self, path: str, **query: str) -> dict[str, Any] | None:
query_string = urllib.parse.urlencode(query)
response = self.request("GET", f"{path}?{query_string}")
results = response.get("results", []) if isinstance(response, dict) else []
return results[0] if results else None
def select_services(slugs: list[str]) -> list[ServiceSpec]:
if not slugs:
return list(SERVICE_SPECS)
by_slug = {service.slug: service for service in SERVICE_SPECS}
unknown = sorted(set(slugs) - set(by_slug))
if unknown:
raise ValueError(f"unknown service slug(s): {', '.join(unknown)}")
return [by_slug[slug] for slug in slugs]
def validate_specs(services: list[ServiceSpec]) -> None:
slugs = [service.slug for service in services]
if len(slugs) != len(set(slugs)):
raise ValueError("duplicate service slug in OIDC roster")
for service in services:
if not service.namespace:
raise ValueError(f"{service.slug} is missing a target namespace")
expressions = "\n".join(mapping["expression"] for mapping in scope_mapping_payloads(service))
missing_claims = [claim for claim in CLAIMS if claim not in expressions]
if missing_claims:
raise ValueError(f"{service.slug} mapping payloads miss claims: {', '.join(missing_claims)}")
def dry_run(services: list[ServiceSpec], args: argparse.Namespace) -> int:
placeholder_ids = [
*[f"<builtin-{managed.rsplit('/', 1)[-1]}-pk>" for managed in BUILTIN_SCOPE_MAPPINGS],
*[f"<{claim}-mapping-pk>" for claim in CLAIMS],
]
documents = []
for service in services:
provider = provider_payload(service, args, placeholder_ids, "<from-1password-client_secret>")
documents.append(
{
"service": service.slug,
"namespace": service.namespace,
"onepassword_item": service.onepassword_item_path,
"issuer_url": service.issuer_url,
"flow_contract": FLOW_CONTRACT,
"builtin_scope_mappings": list(BUILTIN_SCOPE_MAPPINGS),
"scope_mappings": scope_mapping_payloads(service),
"provider": redact(provider),
"application": application_payload(service, "<provider-pk>"),
}
)
if args.print_json:
print(json.dumps(documents, indent=2, sort_keys=True))
else:
print(
"Dry-run only: generated "
f"{len(services)} providers, {len(services)} applications, "
f"and {len(services) * len(CLAIMS)} scope mappings."
)
print("Use --print-json to inspect redacted payloads; use --apply for live Authentik mutation.")
return 0
def apply(services: list[ServiceSpec], args: argparse.Namespace) -> int:
token = os.environ.get("AUTHENTIK_TOKEN")
if not token:
raise ValueError("AUTHENTIK_TOKEN is required with --apply")
if not args.client_secrets_json:
raise ValueError("--client-secrets-json is required with --apply")
secrets = load_client_secrets(args.client_secrets_json)
missing = [service.slug for service in services if not secrets.get(service.slug)]
if missing:
raise ValueError(f"client secret JSON is missing slug(s): {', '.join(missing)}")
client = AuthentikClient(args.base_url, token)
for service in services:
mapping_ids: list[str] = []
for managed in BUILTIN_SCOPE_MAPPINGS:
existing = client.first_result("/propertymappings/provider/scope/", managed=managed)
if not existing:
raise ValueError(f"built-in Authentik scope mapping not found: {managed}")
mapping_ids.append(existing["pk"])
for mapping in scope_mapping_payloads(service):
existing = client.first_result("/propertymappings/provider/scope/", name=mapping["name"])
if existing and not args.update_existing:
mapping_ids.append(existing["pk"])
continue
if existing and args.update_existing:
updated = client.request("PATCH", f"/propertymappings/provider/scope/{existing['pk']}/", mapping)
mapping_ids.append(updated["pk"])
continue
created = client.request("POST", "/propertymappings/provider/scope/", mapping)
mapping_ids.append(created["pk"])
existing_provider = client.first_result("/providers/oauth2/", client_id=service.client_id)
provider_body = provider_payload(service, args, mapping_ids, secrets[service.slug])
if existing_provider and not args.update_existing:
provider_pk = existing_provider["pk"]
elif existing_provider:
updated_provider = client.request("PATCH", f"/providers/oauth2/{existing_provider['pk']}/", provider_body)
provider_pk = updated_provider["pk"]
else:
provider_pk = client.request("POST", "/providers/oauth2/", provider_body)["pk"]
app_body = application_payload(service, provider_pk)
existing_app = client.first_result("/core/applications/", slug=service.slug)
if existing_app and args.update_existing:
client.request("PATCH", f"/core/applications/{existing_app['slug']}/", app_body)
elif not existing_app:
client.request("POST", "/core/applications/", app_body)
print(f"applied {service.slug}: provider/app present, secret redacted")
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="perform live Authentik REST mutations")
parser.add_argument("--update-existing", action="store_true", help="patch existing mappings/providers/applications")
parser.add_argument("--print-json", action="store_true", help="print redacted dry-run payloads")
parser.add_argument("--service", action="append", default=[], help="limit to one service slug; repeatable")
parser.add_argument("--base-url", default="http://localhost:9000", help="Authentik base URL")
parser.add_argument("--client-secrets-json", help="operator-provided JSON object of slug to client_secret")
parser.add_argument("--authorization-flow", default="<authorization-flow-uuid>")
parser.add_argument("--invalidation-flow", default="<invalidation-flow-uuid>")
parser.add_argument("--authentication-flow")
parser.add_argument("--signing-key", help="shared Authentik signing key UUID")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
services = select_services(args.service)
validate_specs(services)
if args.apply:
return apply(services, args)
return dry_run(services, args)
except Exception as error:
print(f"error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,192 +0,0 @@
using FluentAssertions;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Xunit;
using YamlDotNet.RepresentationModel;
namespace BluejayInfraLint.Tests;
[Trait("Category", "AuthentikOidc")]
public sealed class AuthentikOidcClientRegistrationTests
{
private static readonly IReadOnlyList<ServiceClientExpectation> ExpectedClients =
[
new("library", "fc-library"),
new("retail", "fc-retail"),
new("telephony", "telephony"),
new("knowledge", "knowledge"),
new("llmbridge", "fc-llm-bridge"),
new("mysql", "fc-mysql"),
new("php", "fc-php"),
new("signage", "fc-signage"),
new("media", "fc-media"),
new("dms", "fc-dms"),
new("pimanager", "fc-pimanager"),
new("distribution", "fc-distribution"),
new("dns", "fc-dns"),
new("print", "fc-print"),
new("aistation", "fc-aistation"),
new("irc", "irc"),
new("ttsreader", "fc-ttsreader"),
new("chat", "fc-chat"),
new("intranet", "intranet"),
new("remotedesktop", "fc-desktop"),
new("provisioning", "fc-provisioning"),
new("scoreboards", "fc-scoreboard"),
new("mndot", "fc-mndot"),
new("kiosk", "fc-system"),
new("mike-bundle", "fc-mike-bundle"),
new("messageboard", "fc-messageboard"),
new("menuboard", "fc-menuboard"),
new("presentations", "fc-presentations"),
new("segmentdisplay", "fc-segmentdisplay"),
new("signalcontrol", "fc-signalcontrol"),
new("worldbuilder", "fc-worldbuilder"),
new("audit", "fc-audit"),
new("licensing", "fc-licensing"),
];
public static TheoryData<string, string> ExpectedClientRows()
{
var data = new TheoryData<string, string>();
foreach (var client in ExpectedClients)
{
data.Add(client.Slug, client.Namespace);
}
return data;
}
[Theory]
[MemberData(nameof(ExpectedClientRows))]
public void OidcClientManifest_MatchesOnePasswordOperatorContract(string slug, string targetNamespace)
{
var manifest = LoadClientManifest(slug);
manifest.Scalar("apiVersion").Should().Be("onepassword.com/v1");
manifest.Scalar("kind").Should().Be("OnePasswordItem");
manifest.Scalar("metadata", "name").Should().Be($"{slug}-oidc-client");
manifest.Scalar("metadata", "namespace").Should().Be(targetNamespace);
manifest.Scalar("metadata", "labels", "app.kubernetes.io/component")
.Should().Be("authentik-oidc-client");
manifest.Scalar("metadata", "labels", "flowercore.io/authentik-client-slug")
.Should().Be(slug);
manifest.Scalar("metadata", "annotations", "flowercore.io/expected-fields")
.Should().Be("client_id,client_secret,issuer_url");
manifest.Scalar("spec", "itemPath")
.Should().Be($"vaults/IAmWorkin/items/{slug}-oidc-client");
}
[Fact]
public void AuthentikKustomization_ReferencesEveryClientManifest()
{
var kustomizationPath = Path.Combine(BluejayRoot(), "apps", "authentik", "kustomization.yaml");
var text = File.ReadAllText(kustomizationPath);
foreach (var client in ExpectedClients)
{
text.Should().Contain($"clients/{client.Slug}-oidc-client.yaml");
}
Regex.Matches(text, @"clients/[-a-z0-9]+-oidc-client\.yaml")
.Select(match => match.Value)
.Distinct(StringComparer.Ordinal)
.Should()
.HaveCount(ExpectedClients.Count);
}
[Fact]
public void BulkClientScript_HasDryRunDefaultAndRequiredClaimPayloads()
{
var scriptPath = Path.Combine(BluejayRoot(), "scripts", "authentik-bulk-client-create.py");
var script = File.ReadAllText(scriptPath);
script.Should().Contain("--apply");
script.Should().Contain("Dry-run only");
script.Should().Contain("AUTHENTIK_TOKEN");
script.Should().Contain("client_secrets_json");
script.Should().Contain("scope-offline_access");
script.Should().Contain("authorization_code");
script.Should().Contain("refresh_token");
foreach (var claim in new[] { "fc:roles", "fc:tenant", "fc:svc", "fc:scope", "fc:mfa", "flowercore_actor_id" })
{
script.Should().Contain(claim);
}
}
[Fact]
public void BulkClientScript_DryRunGeneratesAllServicesWithoutSecrets()
{
var scriptPath = Path.Combine(BluejayRoot(), "scripts", "authentik-bulk-client-create.py");
var startInfo = new ProcessStartInfo
{
FileName = "python",
WorkingDirectory = BluejayRoot(),
RedirectStandardOutput = true,
RedirectStandardError = true,
};
startInfo.ArgumentList.Add(scriptPath);
startInfo.ArgumentList.Add("--print-json");
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start python dry-run.");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit(15000).Should().BeTrue(stderr);
process.ExitCode.Should().Be(0, stderr);
foreach (var client in ExpectedClients)
{
stdout.Should().Contain($"\"service\": \"{client.Slug}\"");
stdout.Should().Contain($"\"namespace\": \"{client.Namespace}\"");
}
stdout.Should().Contain("\"client_secret\": \"<redacted>\"");
stdout.Should().NotMatchRegex("\"client_secret\"\\s*:\\s*\"(?!<redacted>)[^\"]+\"");
}
[Fact]
public void ClientManifests_DoNotContainInlineSecretMaterial()
{
foreach (var client in ExpectedClients)
{
var path = ClientManifestPath(client.Slug);
var text = File.ReadAllText(path);
text.Should().NotContain("client_secret:");
text.Should().NotContain("password:");
text.Should().NotContain("secret:");
text.Should().Contain($"IAmWorkin/items/{client.Slug}-oidc-client");
}
}
private static YamlMappingNode LoadClientManifest(string slug)
{
using var reader = File.OpenText(ClientManifestPath(slug));
var stream = new YamlStream();
stream.Load(reader);
return stream.Documents[0].RootNode.Should().BeOfType<YamlMappingNode>().Subject;
}
private static string ClientManifestPath(string slug) =>
Path.Combine(BluejayRoot(), "apps", "authentik", "clients", $"{slug}-oidc-client.yaml");
private static string BluejayRoot()
{
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.");
}
private sealed record ServiceClientExpectation(string Slug, string Namespace);
}

View File

@@ -22,16 +22,10 @@ public sealed class FleetManifestLintTests
// (bootstrap-JWT) so its allowlist is GET||HEAD||POST||OPTIONS — but // (bootstrap-JWT) so its allowlist is GET||HEAD||POST||OPTIONS — but
// PUT/PATCH/DELETE must still 404 at the route. Anything wider than this // PUT/PATCH/DELETE must still 404 at the route. Anything wider than this
// set should fail this lint. // set should fail this lint.
//
// PUB-1 (2026-05-06): update.flowercore.io / updates.flowercore.io were
// added for the Cloudflare-proxied public Update Center edge. They use the
// same bounded read-write allowlist as the LAN pair.
private static readonly HashSet<string> PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal) private static readonly HashSet<string> PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal)
{ {
"updatecenter.iamworkin.lan", "updatecenter.iamworkin.lan",
"updates.iamworkin.lan", "updates.iamworkin.lan",
"update.flowercore.io",
"updates.flowercore.io",
}; };
private static readonly HashSet<string> ApiKeyProtectedDeployments = new(StringComparer.Ordinal) private static readonly HashSet<string> ApiKeyProtectedDeployments = new(StringComparer.Ordinal)

View File

@@ -6,12 +6,7 @@ package bluejayinfra.public_readwrite_allowlist
# PUT/PATCH/DELETE must still 404 at the route. Any host in this set MUST # PUT/PATCH/DELETE must still 404 at the route. Any host in this set MUST
# include all four required methods AND MUST NOT include any forbidden # include all four required methods AND MUST NOT include any forbidden
# method. # method.
public_readwrite_hosts := { public_readwrite_hosts := {"updatecenter.iamworkin.lan", "updates.iamworkin.lan"}
"updatecenter.iamworkin.lan",
"updates.iamworkin.lan",
"update.flowercore.io",
"updates.flowercore.io",
}
required_methods := {"GET", "HEAD", "POST", "OPTIONS"} required_methods := {"GET", "HEAD", "POST", "OPTIONS"}