Compare commits
239 Commits
848288af7a
...
claude/fc-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbc07023b | ||
|
|
4b0eef0fb0 | ||
|
|
bb09a3786f | ||
|
|
006dbcf671 | ||
|
|
1be71d6ba7 | ||
|
|
0c8026c912 | ||
|
|
621ae47e00 | ||
|
|
ae6b8c0142 | ||
|
|
da55220218 | ||
|
|
b1ad253dd6 | ||
|
|
ee935f6e07 | ||
|
|
2853ee2024 | ||
|
|
b4a34e16ca | ||
|
|
0d5a1fd530 | ||
|
|
1b633f57b2 | ||
|
|
ee8afd0a08 | ||
|
|
cf35884eae | ||
|
|
9881767b11 | ||
|
|
c9bf23834b | ||
|
|
174002023d | ||
|
|
b71f9e4ec9 | ||
|
|
f1431f7324 | ||
|
|
35bd055cb4 | ||
|
|
f604ab419e | ||
|
|
b2786252b0 | ||
|
|
45ee40920d | ||
|
|
8ad7eb714b | ||
|
|
3cb44c3104 | ||
|
|
2400329acd | ||
|
|
c17af882cc | ||
|
|
76b1938afa | ||
|
|
ced04a6148 | ||
|
|
f2258b92a2 | ||
|
|
979a7c7b25 | ||
|
|
0df8f7b936 | ||
|
|
38558641c1 | ||
|
|
63d905b4df | ||
|
|
d95f4e0caf | ||
|
|
7bc565d17e | ||
|
|
dfe9c3b67e | ||
|
|
37f8db89e4 | ||
|
|
00c7d8df24 | ||
|
|
c6811eadd8 | ||
|
|
4d9d537d83 | ||
|
|
0f9d56ee16 | ||
|
|
3bf6511d5d | ||
|
|
3e0b9055b0 | ||
|
|
c828832808 | ||
|
|
e2c71c2b8a | ||
|
|
b3028f5119 | ||
|
|
05a273d3a6 | ||
|
|
ab6ade4e46 | ||
|
|
4848f72eec | ||
|
|
f5eafc5def | ||
|
|
2d3fd74bab | ||
|
|
df4e1f78b0 | ||
|
|
2a10b775a8 | ||
|
|
447ddd339d | ||
|
|
7833143c1c | ||
|
|
8ed77c4627 | ||
|
|
437f346aee | ||
|
|
bc32b5ef04 | ||
|
|
263d06acb9 | ||
|
|
25dbb2967f | ||
|
|
a89a774eaf | ||
|
|
dc39747f3f | ||
|
|
87050e72a9 | ||
|
|
e8c5d2afd2 | ||
|
|
eef492125f | ||
|
|
b51ee35bfa | ||
|
|
4abc2fa95d | ||
|
|
d7628a6945 | ||
|
|
df115e4d1e | ||
|
|
9df26620b8 | ||
|
|
08aa7a5bff | ||
|
|
38e20a8b64 | ||
|
|
c945d44b9e | ||
|
|
1f1354f634 | ||
|
|
76ece92cfd | ||
|
|
a760a58846 | ||
|
|
9fb526c7c5 | ||
|
|
dd7980642e | ||
|
|
1d4ad64226 | ||
|
|
774f82c431 | ||
|
|
d2cc36ea0e | ||
|
|
299070e4bf | ||
|
|
a9debd8668 | ||
|
|
675b9da4f9 | ||
|
|
2b471a55b0 | ||
|
|
37ce0aed85 | ||
|
|
a37fc83584 | ||
|
|
3a8aae9e2d | ||
|
|
020a806d08 | ||
|
|
e65de2938b | ||
|
|
5c0c21790e | ||
|
|
292528ec15 | ||
|
|
bb39a0c1fd | ||
|
|
c23e903ba7 | ||
|
|
cae03296f5 | ||
|
|
3c5c1a07bd | ||
|
|
057595de3d | ||
|
|
b02bb4be38 | ||
|
|
e44e9a0062 | ||
|
|
297a2a9bbc | ||
|
|
d4210c819f | ||
|
|
fc0b67f670 | ||
|
|
223e9a9232 | ||
|
|
6c1375b21a | ||
|
|
82529ed9b5 | ||
|
|
3ea8a56dab | ||
|
|
9272abc225 | ||
|
|
436185818d | ||
|
|
c3cc404beb | ||
|
|
90627819cc | ||
|
|
c97d486a3d | ||
|
|
209bdc16cd | ||
|
|
3999634b06 | ||
|
|
61538d3712 | ||
|
|
ccaac367af | ||
|
|
407d473b71 | ||
|
|
f9593e494a | ||
|
|
5b6c7b97fc | ||
|
|
a76eeb5c39 | ||
|
|
8a960ffc73 | ||
|
|
686dbacc66 | ||
|
|
5ccf055465 | ||
|
|
4da60820c6 | ||
|
|
1cc4324cfb | ||
|
|
bfc755057e | ||
|
|
d6008ee205 | ||
|
|
39fe6f1dba | ||
|
|
90fcf0cd5d | ||
|
|
ffef5c9126 | ||
|
|
634e90a9ee | ||
|
|
86ccca18e3 | ||
|
|
1c5caf3f40 | ||
|
|
d3db19b0ca | ||
|
|
702a6e4f52 | ||
|
|
6cbb5d8792 | ||
|
|
62db15c69c | ||
|
|
84634f59f0 | ||
|
|
4cd5806fd0 | ||
|
|
11c48bef30 | ||
|
|
a86e87050b | ||
|
|
0214f94ac4 | ||
|
|
a1b8eb379d | ||
|
|
9a1665907c | ||
|
|
899804215a | ||
|
|
1dc66738e6 | ||
|
|
5623a272c5 | ||
|
|
3d3f91160b | ||
|
|
93f77c1844 | ||
|
|
59efc460fd | ||
|
|
02959f1ac6 | ||
|
|
a3aa84bdae | ||
|
|
01cb9a557f | ||
|
|
0fa46ad53b | ||
|
|
1ded5a61c0 | ||
|
|
3c1d212251 | ||
|
|
c0547a9964 | ||
|
|
973c1dae72 | ||
|
|
475737b36f | ||
|
|
3bb3801fbd | ||
|
|
28b76001a8 | ||
|
|
0c67fa5356 | ||
|
|
62e342cfb2 | ||
|
|
90deacd154 | ||
|
|
f0733ff89d | ||
|
|
313bdcb21a | ||
|
|
5f4818bd96 | ||
|
|
fff998dab5 | ||
|
|
20e4130c74 | ||
|
|
3cf675b8c3 | ||
|
|
2a9f2e4540 | ||
|
|
b15a35a258 | ||
|
|
3f4985ee13 | ||
|
|
e535a8d34b | ||
|
|
6ddbd2cae5 | ||
|
|
e9608651f7 | ||
|
|
abdb7a806e | ||
|
|
7afb5043c4 | ||
|
|
1883953cb8 | ||
|
|
9c555db083 | ||
|
|
cb349c6764 | ||
|
|
3888c4c3e0 | ||
|
|
7aec403e96 | ||
|
|
5685ab0550 | ||
|
|
d4d3455ef2 | ||
|
|
29d557003f | ||
|
|
719aa8c1c6 | ||
|
|
63cf5193ef | ||
|
|
ef0e1f2505 | ||
|
|
f8eb946704 | ||
|
|
929449c55c | ||
|
|
9d0da584af | ||
|
|
4f33d7a053 | ||
|
|
d3ffad9190 | ||
|
|
403d061664 | ||
|
|
45a2cb3f93 | ||
|
|
e1922564ae | ||
|
|
7762a0079a | ||
|
|
ab7435a43a | ||
|
|
53234bfcc8 | ||
|
|
cf572c167f | ||
|
|
7d5d0f86e7 | ||
|
|
8f59322329 | ||
|
|
8f8290e0da | ||
|
|
607192aaec | ||
|
|
072d64a5e9 | ||
|
|
acb19bee9c | ||
|
|
e6fbe2d22b | ||
|
|
dbd6769537 | ||
|
|
0af47f893a | ||
|
|
d16f72f089 | ||
|
|
36e7369609 | ||
|
|
3e5c017c4e | ||
|
|
67e41febf5 | ||
|
|
c9f07108bd | ||
|
|
f3919cf728 | ||
|
|
56442ecfbc | ||
|
|
a07b6311b9 | ||
|
|
331ae14d3f | ||
|
|
b291d0360b | ||
|
|
090b29933f | ||
|
|
987b73c537 | ||
|
|
bf12474de9 | ||
|
|
f366dd5c90 | ||
|
|
50146f8355 | ||
|
|
ace06c5fb9 | ||
|
|
7ed834f056 | ||
|
|
2b04c9e292 | ||
|
|
fafc2e510b | ||
|
|
fb1c622e62 | ||
|
|
40cb7faef5 | ||
|
|
bd79279b28 | ||
|
|
35b6b4f8e5 | ||
|
|
8d8b76c82b | ||
|
|
f3fde15002 | ||
|
|
42d2894ed1 |
109
README.md
109
README.md
@@ -1,3 +1,110 @@
|
||||
# bluejay-infra
|
||||
|
||||
Infrastructure manifests for ArgoCD
|
||||
Infrastructure manifests for ArgoCD. An `ApplicationSet` in `argocd` namespace watches the `apps/*` directories in this repo and creates one `Application` per subdir (prefixed `infra-<name>`).
|
||||
|
||||
## Adding a new service to the cluster
|
||||
|
||||
Follow these steps in order. **Step 1 must run before step 3** — if you skip it, cert-manager HTTP-01 will silently fail for ~2h per cert (exponential backoff) until someone diagnoses the DNS.
|
||||
|
||||
### 1. Create or verify the FlowerCore.DNS A record (REQUIRED for current HTTP-01 manifests)
|
||||
|
||||
step-ca (the ACME CA on noc1) runs in a Podman container with host networking. Its container resolver uses pfSense Unbound (10.0.56.1), **not** cluster CoreDNS. So even though CoreDNS has a wildcard `*.iamworkin.lan → 10.0.56.200` for in-cluster lookups, step-ca cannot see it. Every new public hostname needs an explicit pfSense host override.
|
||||
|
||||
The management path is now `FlowerCore.DNS`, not `FlowerCore.Notes/scripts/pfsense-add-dns-overrides.py`. Add or verify the public A record there before you apply the manifest:
|
||||
|
||||
```bash
|
||||
curl -sk https://dns.iamworkin.lan/api/v1/servers
|
||||
# Find the pfSense serverId, then create the record using the host label only.
|
||||
# Example: for foo.iamworkin.lan, use "name":"foo".
|
||||
|
||||
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"<yourservice>","type":"A","data":"10.0.56.200","ttl":300}'
|
||||
```
|
||||
|
||||
Verify all referenced iamworkin.lan hosts resolve (run from anywhere on LAN):
|
||||
|
||||
```bash
|
||||
python scripts/check-pfsense-dns.py
|
||||
# Historical filename retained. The script now calls
|
||||
# https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight
|
||||
# for every Certificate dnsName and Traefik Host(...) rule it finds.
|
||||
|
||||
python scripts/check-pfsense-dns.py --live
|
||||
# Optional stronger pass when kubectl access is available; also checks
|
||||
# live-cluster Certificates and IngressRoutes for drift outside manifests.
|
||||
```
|
||||
|
||||
**Symptom if you skip this:** the Certificate resource stays `Ready: False` with `status.reason: unexpected non-ACME API error: context deadline exceeded`. Recovery requires `kubectl -n <ns> delete order <order-name>` after adding the DNS to bypass cert-manager's backoff.
|
||||
|
||||
### 2. Create the app manifest
|
||||
|
||||
Create `apps/<name>/<name>.yaml` containing the Namespace, Deployment, Service, Certificate, and IngressRoute. Reference an existing directory (e.g. `apps/fc-messageboard/`) for the canonical shape.
|
||||
|
||||
Conventions:
|
||||
|
||||
- `Namespace` has label `app.kubernetes.io/part-of: bluejay-infra`
|
||||
- `Deployment.spec.selector.matchLabels` and `Service.spec.selector` MUST use the same label key. The historical convention here is `app: <name>` (not `app.kubernetes.io/name`) — don't mix.
|
||||
- Image: `localhost/<name>:v<YYYYMMDD><HHMM>`, `imagePullPolicy: Never`. Import the image to every RKE2 node (server + both agents) via `ctr images import` before applying — pods schedule anywhere.
|
||||
- If the app persists local state (SQLite, uploads), declare the `PersistentVolumeClaim` here with `storageClassName: longhorn` and `accessModes: [ReadWriteOnce]`. Add `strategy.type: Recreate` to the Deployment — RWO PVC blocks rolling updates.
|
||||
- Probes: use `tcpSocket` if the app has middleware that intercepts unauth requests (returns 404/401 for `/health`). Otherwise prefer `httpGet` against whatever the app exposes (verify the path isn't gated by auth).
|
||||
- Certificate: `issuerRef.name: step-ca-acme`, `issuerRef.kind: ClusterIssuer`. `dnsNames` must match the hostname you created in FlowerCore.DNS in step 1.
|
||||
|
||||
### 3. Commit & push
|
||||
|
||||
```bash
|
||||
git add apps/<name>/
|
||||
git commit -m "<name>: initial deployment"
|
||||
git push
|
||||
```
|
||||
|
||||
ArgoCD's `ApplicationSet` picks up the new directory within ~3 minutes and creates `infra-<name>` with auto-sync + self-heal enabled.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
```bash
|
||||
# From noc1
|
||||
fcadmin_ssh noc1 '
|
||||
kubectl -n argocd get application infra-<name>
|
||||
kubectl -n <ns> get certificate,pod
|
||||
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://<name>.iamworkin.lan/
|
||||
'
|
||||
```
|
||||
|
||||
Certificate should be `Ready: True` within ~60s. If it stalls `False` for >2m, the pfSense DNS step got skipped — go back to step 1, then `kubectl -n <ns> delete order <order-name>` to bust the backoff.
|
||||
|
||||
### Pre-merge gate
|
||||
|
||||
Before `git push`, always run:
|
||||
|
||||
```bash
|
||||
python scripts/check-pfsense-dns.py
|
||||
```
|
||||
|
||||
It's a quick service-backed check that would have caught the entire 2026-04-22 cert-manager outage. Consider wiring it into a pre-commit hook or a Gitea Actions workflow.
|
||||
|
||||
## Retiring a service
|
||||
|
||||
1. `kubectl -n argocd delete application infra-<name>` (cascade deletes the K8s resources via ArgoCD finalizers)
|
||||
2. `git rm -r apps/<name>/` and push
|
||||
3. Remove the FlowerCore.DNS record through the UI or API, for example:
|
||||
|
||||
```bash
|
||||
curl -sk https://dns.iamworkin.lan/api/v1/servers
|
||||
curl -sk -X DELETE https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records/<yourservice>
|
||||
```
|
||||
|
||||
## Known gotchas
|
||||
|
||||
- **CoreDNS template + ndots:5 collision**: inside pods, `<svc>.<ns>.svc.cluster.local` with <5 dots gets search-expanded through `iamworkin.lan` FIRST and hits the wildcard template → resolves to Traefik VIP, not the real ClusterIP. Use short service names (`<svc>`) in K8s manifests. See memory `feedback_coredns_ndots_template_collision.md`.
|
||||
- **Image not on node**: pods stuck `ErrImageNeverPull` means the image wasn't imported to the node Kubernetes scheduled the pod onto. `ctr images import` on all of rke2-server, rke2-agent1, rke2-agent2.
|
||||
- **StatefulSet PVC drift**: `volumeClaimTemplates` needs explicit `volumeMode: Filesystem` or ArgoCD SSA self-heals forever. See memory `feedback_argocd_statefulset_pvc_drift.md`.
|
||||
- **ArgoCD must use internal Gitea URL**: `http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git`, not the external HTTPS URL (step-ca cert isn't trusted by ArgoCD). The `ApplicationSet` and any hand-created `Application` must both use the internal URL.
|
||||
|
||||
## References
|
||||
|
||||
- Cert-manager recovery playbook: `FlowerCore.Notes/memory/project_cert_manager_recovery_2026_04_22.md`
|
||||
- Why pfSense DNS is required: `FlowerCore.Notes/memory/feedback_pfsense_dns_required_for_acme.md`
|
||||
- Public DNS operator host: `https://dns.iamworkin.lan`
|
||||
- Canonical credential helper: `FlowerCore.Notes/scripts/credential-helper.sh`
|
||||
- pfSense admin automation: `FlowerCore.Notes/memory/feedback_pfsense_automation.md`
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
# =============================================================================
|
||||
# Agent Zero AI Stack — NUC Deployment (RKE2 Bare-Metal)
|
||||
# =============================================================================
|
||||
# Deploys: AgentZero (agent UI) on RKE2 cluster
|
||||
# Ollama: edge1 Pi 5 at 10.0.57.15:11434 (qwen2.5-coder:7b, CPU)
|
||||
# Deploys: AgentZero (agent UI) on RKE2 cluster with Blue Jay profile
|
||||
# Ollama: edge1 Pi 5 + AI HAT+ ONLY (10.0.57.17:11434).
|
||||
# Workstation Ollama (BLUEJAY-WS) is intentionally NOT in the upstream —
|
||||
# the workstation is private dev hardware, not a cluster dependency.
|
||||
# Target: RKE2 bare-metal cluster, namespace: agent-zero
|
||||
# Profile: Blue Jay (21 tools, 3 prompts, 4 extensions, theme)
|
||||
#
|
||||
# Differences from LOCAL (WSL K3s):
|
||||
# - Uses Longhorn StorageClass (not local-path)
|
||||
# - Connects to edge1 Pi 5 Ollama (not workstation R9700)
|
||||
# - Cluster-only Ollama path (edge1) — keeps workstation private
|
||||
# - NO Anthropic API key (free/local models only)
|
||||
# - NO Piper TTS or Kiwix (edge1 handles TTS, no Wikipedia needed)
|
||||
# - NO hostPath volumes (no access to Windows filesystem)
|
||||
# - NO hostPath volumes — profile/tools/extensions loaded via ConfigMaps
|
||||
# - Traefik IngressRoute for LAN access at agent-zero.iamworkin.lan
|
||||
# - Knowledge base loaded via ConfigMap (not hostPath)
|
||||
#
|
||||
# Available Ollama models on edge1:
|
||||
# - qwen2.5-coder:7b ~4.7 GB Code generation (CPU, Q4_K_M)
|
||||
# ConfigMaps (defined in configmaps-bluejay.yaml):
|
||||
# bluejay-tools 21 Python tool modules (~520K)
|
||||
# bluejay-profile agent.json, agent.yaml, system_prompt.md (~20K)
|
||||
# bluejay-prompts 3 prompt templates (~11K)
|
||||
# flowercore-extensions 5 Python extension modules (~76K)
|
||||
# bluejay-theme CSS theme (~7K)
|
||||
#
|
||||
# Apply: KUBECONFIG=~/.kube/rke2.yaml kubectl apply -f agent-zero-nuc.yaml
|
||||
# =============================================================================
|
||||
@@ -84,10 +90,54 @@ subjects:
|
||||
namespace: agent-zero
|
||||
|
||||
# =============================================================================
|
||||
# Agent Zero — AI Agent Web UI (NUC Edition)
|
||||
# Agent Zero — AI Agent Web UI (NUC Edition, Blue Jay Profile)
|
||||
# =============================================================================
|
||||
# Connects to edge1 Pi 5 Ollama (free, local models only)
|
||||
# No paid API keys — uses qwen2.5-coder:7b for everything
|
||||
# Connects directly to fc-llm-bridge for chat + internal util/embed + browser.
|
||||
# Agent Zero's internal util/embed slots stay on the bridge's OpenAI-compatible
|
||||
# /v1 surface, while browser + corpus-search use the Ollama-compatible /api/*
|
||||
# surface through OLLAMA_HOST.
|
||||
# Blue Jay profile with 21 tools, 3 prompts, 4 extensions.
|
||||
|
||||
---
|
||||
# FC LLM Bridge API key for Agent Zero (ADR-088 chat/util/embed/browser routing).
|
||||
# Syncs from 1Password item "FC LLM Bridge API Keys" (field: agent-zero-k8s).
|
||||
# Consumed by chat, internal util/embed, browser, and corpus-search requests
|
||||
# that traverse fc-llm-bridge.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: fc-llm-bridge-api-keys
|
||||
namespace: agent-zero
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FC LLM Bridge API Keys"
|
||||
|
||||
---
|
||||
# Print.Web API key for Agent Zero's print_web.py Python tool.
|
||||
# Syncs from 1Password item "Print.Web API Keys" (password field = API key).
|
||||
# The print_web.py tool reads PRINT_WEB_API_KEY env var for all HTTP requests
|
||||
# to the thermal print service (GET /api/mcp/tools, POST /api/print/*, etc.).
|
||||
# Note: Print.Web uses the legacy REST MCP shape (/api/mcp/tools/*), not the
|
||||
# streamable-http MCP protocol. The print_web Python tool bridges this gap
|
||||
# and is already present in bluejay-tools ConfigMaps.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: print-web-api-keys
|
||||
namespace: agent-zero
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/Print.Web API Keys"
|
||||
|
||||
---
|
||||
# Knowledge MCP bearer token for the direct Agent Zero -> Knowledge.Web path.
|
||||
# The 1Password item currently stores the raw token in its concealed PASSWORD
|
||||
# field, which the operator syncs to Secret key `password`.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: knowledge-mcp-tokens
|
||||
namespace: agent-zero
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
@@ -99,7 +149,8 @@ metadata:
|
||||
app: agent-zero
|
||||
annotations:
|
||||
agent-zero/deployment: "nuc"
|
||||
agent-zero/ollama: "edge1 Pi 5 (10.0.57.15:11434)"
|
||||
agent-zero/profile: "bluejay"
|
||||
agent-zero/ollama: "fc-llm-bridge fronts edge1 Pi 5 + AI HAT+ Ollama for cluster browser/corpus-search traffic; internal chat/util/embed route through the bridge's authenticated OpenAI surface"
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
@@ -113,6 +164,64 @@ spec:
|
||||
app: agent-zero
|
||||
spec:
|
||||
serviceAccountName: agent-zero
|
||||
initContainers:
|
||||
# Wait for fc-llm-bridge to be reachable before starting Agent Zero.
|
||||
- name: wait-for-llm-bridge
|
||||
image: busybox:1.37
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
echo "Waiting for fc-llm-bridge..."
|
||||
until wget -qO- --timeout=2 http://fc-llm-bridge.fc-llm-bridge.svc:8080/healthz >/dev/null 2>&1; do
|
||||
echo "fc-llm-bridge not ready yet, retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo "fc-llm-bridge is reachable."
|
||||
# Assemble the Blue Jay profile directory structure from ConfigMaps.
|
||||
# ConfigMaps can't create nested dirs, so we copy into the workspace PVC.
|
||||
- name: setup-bluejay
|
||||
image: busybox:1.37
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
echo "Setting up Blue Jay profile..."
|
||||
# Profile root files
|
||||
mkdir -p /a0/work/.bluejay/agents/bluejay/tools
|
||||
mkdir -p /a0/work/.bluejay/agents/bluejay/prompts
|
||||
cp /tmp/bluejay-profile/* /a0/work/.bluejay/agents/bluejay/
|
||||
# Tools (split across 3 ConfigMaps to stay under K8s 262K annotation limit)
|
||||
cp /tmp/bluejay-tools-a/* /a0/work/.bluejay/agents/bluejay/tools/
|
||||
cp /tmp/bluejay-tools-b/* /a0/work/.bluejay/agents/bluejay/tools/
|
||||
cp /tmp/bluejay-tools-c/* /a0/work/.bluejay/agents/bluejay/tools/
|
||||
# Prompts
|
||||
cp /tmp/bluejay-prompts/* /a0/work/.bluejay/agents/bluejay/prompts/
|
||||
# Extensions
|
||||
mkdir -p /a0/work/.bluejay/extensions/flowercore
|
||||
cp /tmp/flowercore-extensions/* /a0/work/.bluejay/extensions/flowercore/
|
||||
# Theme
|
||||
mkdir -p /a0/work/.bluejay/theme
|
||||
cp /tmp/bluejay-theme/* /a0/work/.bluejay/theme/
|
||||
echo "Blue Jay profile ready:"
|
||||
echo " Tools: $(ls /a0/work/.bluejay/agents/bluejay/tools/*.py | wc -l)"
|
||||
echo " Prompts: $(ls /a0/work/.bluejay/agents/bluejay/prompts/*.md | wc -l)"
|
||||
echo " Extensions: $(ls /a0/work/.bluejay/extensions/flowercore/*.py | wc -l)"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /a0/work
|
||||
- name: bluejay-tools-a
|
||||
mountPath: /tmp/bluejay-tools-a
|
||||
- name: bluejay-tools-b
|
||||
mountPath: /tmp/bluejay-tools-b
|
||||
- name: bluejay-tools-c
|
||||
mountPath: /tmp/bluejay-tools-c
|
||||
- name: bluejay-profile
|
||||
mountPath: /tmp/bluejay-profile
|
||||
- name: bluejay-prompts
|
||||
mountPath: /tmp/bluejay-prompts
|
||||
- name: flowercore-extensions
|
||||
mountPath: /tmp/flowercore-extensions
|
||||
- name: bluejay-theme
|
||||
mountPath: /tmp/bluejay-theme
|
||||
containers:
|
||||
- name: agent-zero
|
||||
image: agent0ai/agent-zero:latest
|
||||
@@ -127,6 +236,48 @@ spec:
|
||||
chmod +x kubectl && mv kubectl /usr/local/bin/kubectl && \
|
||||
cp /usr/local/bin/kubectl /a0/work/kubectl
|
||||
fi
|
||||
# Link Blue Jay profile from workspace into Agent Zero's expected path
|
||||
ln -sfn /a0/work/.bluejay/agents/bluejay /a0/agents/bluejay
|
||||
# Write model config BEFORE initialize.sh loads it
|
||||
# The _model_config plugin reads config.json (NOT config.yaml).
|
||||
# chat_model: FlowerCore LLM Bridge (ADR-088) — OpenAI-compat,
|
||||
# spend-tracked, tier-aliased (fc:balanced → Claude Sonnet).
|
||||
# api_key comes from A0_SET_chat_model_api_key env var (overrides
|
||||
# config.json). Utility + embedding stay on the authenticated
|
||||
# OpenAI-compatible /v1 surface; browser and direct tool traffic
|
||||
# use the bridge's Ollama-compatible root via OLLAMA_HOST.
|
||||
mkdir -p /a0/usr/plugins/_model_config
|
||||
cat > /a0/usr/plugins/_model_config/config.json << 'MODELCFG'
|
||||
{"allow_chat_override":true,"chat_model":{"provider":"openai","name":"fc:balanced","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_history":0.7,"vision":false,"kwargs":{"temperature":0,"num_ctx":8192}},"utility_model":{"provider":"openai","name":"fc:cheap","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_input":0.7,"kwargs":{"num_ctx":8192}},"embedding_model":{"provider":"openai","name":"openai/fc:embedding","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","kwargs":{}}}
|
||||
MODELCFG
|
||||
# Strip heredoc indentation
|
||||
sed -i 's/^ //' /a0/usr/plugins/_model_config/config.json
|
||||
# Phase 0 Chat MCP pilot: Agent Zero does not interpolate env vars
|
||||
# inside A0_SET_mcp_servers JSON, so build the final JSON here from
|
||||
# the secret-backed env vars before initialize.sh. Keep the local
|
||||
# corpus_search.py tool mounted either way so outage fallback
|
||||
# remains available even when fc_knowledge is not advertised.
|
||||
export KNOWLEDGE_MCP_ENABLED=false
|
||||
if [ -n "${KNOWLEDGE_MCP_BEARER_TOKEN:-}" ]; then
|
||||
if curl -sf --connect-timeout 3 "${KNOWLEDGE_MCP_HEALTH_URL}" > /dev/null && \
|
||||
curl -sf --connect-timeout 5 \
|
||||
-H "Authorization: Bearer ${KNOWLEDGE_MCP_BEARER_TOKEN}" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":"fc-knowledge-bootstrap","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agent-zero-bootstrap","version":"1.0"}}}' \
|
||||
"${KNOWLEDGE_MCP_URL}" > /dev/null; then
|
||||
export KNOWLEDGE_MCP_ENABLED=true
|
||||
echo "fc_knowledge enabled from ${KNOWLEDGE_MCP_URL}."
|
||||
else
|
||||
echo "fc_knowledge unavailable or unauthorized; keeping local corpus_search.py as the fallback path."
|
||||
fi
|
||||
else
|
||||
echo "fc_knowledge token missing; keeping local corpus_search.py as the fallback path."
|
||||
fi
|
||||
|
||||
export A0_SET_mcp_servers="$(
|
||||
python3 -c 'import json, os; servers = {}; chat_key = os.getenv("CHAT_MCP_API_KEY"); knowledge_enabled = os.getenv("KNOWLEDGE_MCP_ENABLED", "false").lower() == "true"; token = os.getenv("KNOWLEDGE_MCP_BEARER_TOKEN", "") if knowledge_enabled else ""; chat_key and servers.setdefault("fc_chat", {"type": "streamable-http", "url": "http://chat-web.fc-chat.svc/mcp", "headers": {"X-Api-Key": chat_key}}); token and servers.setdefault("fc_knowledge", {"type": "streamable-http", "url": os.getenv("KNOWLEDGE_MCP_URL", "http://knowledge-web.knowledge.svc/mcp"), "headers": {"Authorization": f"Bearer {token}"}}); print(json.dumps({"mcpServers": servers}, separators=(",", ":")))'
|
||||
)"
|
||||
# Run the original entrypoint
|
||||
exec /exe/initialize.sh $BRANCH
|
||||
ports:
|
||||
@@ -135,45 +286,81 @@ spec:
|
||||
# Agent identity
|
||||
- name: AGENT_NAME
|
||||
value: "Blue Jay (NUC)"
|
||||
# Chat model — qwen2.5-coder:7b on edge1 Pi 5
|
||||
# Chat model — routed through FlowerCore LLM Bridge (ADR-088)
|
||||
# so spend is tracked and tier aliases (fc:cheap/fc:balanced/fc:deep)
|
||||
# dispatch to Ollama or Anthropic via a single OpenAI-compat endpoint.
|
||||
# Internal utility + embedding use the authenticated OpenAI surface,
|
||||
# while browser/corpus-search use the bridge's Ollama-compatible
|
||||
# endpoints so Agent Zero no longer needs a local proxy sidecar.
|
||||
- name: A0_SET_chat_model_provider
|
||||
value: "ollama"
|
||||
value: "openai"
|
||||
- name: A0_SET_chat_model_name
|
||||
value: "qwen2.5-coder:7b"
|
||||
value: "fc:balanced"
|
||||
- name: A0_SET_chat_model_api_base
|
||||
value: "http://10.0.57.15:11434"
|
||||
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
|
||||
- name: A0_SET_chat_model_api_key
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: agent-zero-k8s
|
||||
# Agent Zero's runtime still resolves provider keys from the
|
||||
# provider-level env names (models.get_api_key -> OPENAI_API_KEY /
|
||||
# API_KEY_OPENAI), not the slot-scoped A0_SET_* value alone.
|
||||
# Mirror the same secret here so real public chat runs can reach
|
||||
# the fc-llm-bridge chat_model path instead of failing before MCP.
|
||||
- name: OPENAI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: agent-zero-k8s
|
||||
- name: FC_LLM_BRIDGE_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: agent-zero-k8s
|
||||
- name: A0_SET_chat_model_ctx_length
|
||||
value: "32768"
|
||||
value: "8192"
|
||||
- name: A0_SET_chat_model_kwargs
|
||||
value: '{"temperature": 0, "num_ctx": 32768}'
|
||||
# Utility model — same as chat (only one model available)
|
||||
value: '{"temperature": 0, "num_ctx": 8192}'
|
||||
# Utility model — fast small helper tier through the OpenAI surface
|
||||
- name: A0_SET_util_model_provider
|
||||
value: "ollama"
|
||||
value: "openai"
|
||||
- name: A0_SET_util_model_name
|
||||
value: "qwen2.5-coder:7b"
|
||||
value: "fc:cheap"
|
||||
- name: A0_SET_util_model_api_base
|
||||
value: "http://10.0.57.15:11434"
|
||||
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
|
||||
- name: A0_SET_util_model_kwargs
|
||||
value: '{"num_ctx": 8192}'
|
||||
# Embedding model — nomic on edge1 (if installed, fallback to none)
|
||||
value: '{"num_ctx": 2048}'
|
||||
# Embedding model — authenticated bridge alias to nomic-embed-text.
|
||||
# LiteLLM's embedding() path needs an explicit provider prefix here
|
||||
# even though the chat slot can use bare fc:* aliases.
|
||||
- name: A0_SET_embed_model_provider
|
||||
value: "ollama"
|
||||
value: "openai"
|
||||
- name: A0_SET_embed_model_name
|
||||
value: "nomic-embed-text"
|
||||
value: "openai/fc:embedding"
|
||||
- name: A0_SET_embed_model_api_base
|
||||
value: "http://10.0.57.15:11434"
|
||||
# Browser model — disabled (no vision model on Pi)
|
||||
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
|
||||
# Browser model — small Gemma candidate through the same proxy
|
||||
- name: A0_SET_browser_model_provider
|
||||
value: "ollama"
|
||||
- name: A0_SET_browser_model_name
|
||||
value: "qwen2.5-coder:7b"
|
||||
value: "gemma3:4b"
|
||||
- name: A0_SET_browser_model_api_base
|
||||
value: "http://10.0.57.15:11434"
|
||||
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
|
||||
- name: A0_SET_browser_model_api_key
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: agent-zero-k8s
|
||||
- name: A0_SET_browser_model_vision
|
||||
value: "false"
|
||||
# Agent profile
|
||||
value: "true"
|
||||
- name: OLLAMA_HOST
|
||||
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
|
||||
- name: FLOWERCORE_AGENTZERO_OLLAMA_URL
|
||||
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
|
||||
# Agent profile — Blue Jay personality, tools, and system prompt
|
||||
- name: A0_SET_agent_profile
|
||||
value: "default"
|
||||
value: "bluejay"
|
||||
# Memory settings
|
||||
- name: A0_SET_memory_memorize_enabled
|
||||
value: "true"
|
||||
@@ -186,6 +373,45 @@ spec:
|
||||
# Speech-to-text disabled (no GPU for Whisper)
|
||||
- name: A0_SET_stt_model_size
|
||||
value: "tiny"
|
||||
# FlowerCore.Chat MCP pilot (Phase 0)
|
||||
- name: CHAT_MCP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: chat-mcp-api-key
|
||||
key: api-key
|
||||
optional: true
|
||||
# FlowerCore.Knowledge MCP Phase 1 — direct Agent Zero client path.
|
||||
# Probe /healthz first, then try an authenticated initialize call.
|
||||
# If either fails, Agent Zero boots without fc_knowledge and keeps
|
||||
# the local corpus_search.py tool as the outage-safe path.
|
||||
- name: KNOWLEDGE_MCP_URL
|
||||
value: "http://knowledge-web.knowledge.svc/mcp"
|
||||
- name: KNOWLEDGE_MCP_HEALTH_URL
|
||||
value: "http://knowledge-web.knowledge.svc/healthz"
|
||||
- name: KNOWLEDGE_MCP_BEARER_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: knowledge-mcp-tokens
|
||||
key: password
|
||||
# Print.Web — Thermal printer service on edge2.
|
||||
# PRINT_WEB_URL: internal HTTP (bypasses Traefik TLS — print_web.py
|
||||
# runs in-cluster and can reach edge2 directly on the PROD VLAN).
|
||||
# PRINT_WEB_API_KEY: from 1Password "Print.Web API Keys" password field,
|
||||
# synced by the print-web-api-keys OnePasswordItem CRD above.
|
||||
# The print_web.py Python tool reads both env vars for all HTTP calls.
|
||||
- name: PRINT_WEB_URL
|
||||
value: "http://10.0.57.16:5200"
|
||||
- name: PRINT_WEB_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: print-web-api-keys
|
||||
key: password
|
||||
# Intranet search — use in-cluster HTTP (no step-ca TLS needed)
|
||||
# corpus_search.py reads FLOWERCORE_FLEET_VECTOR_DIR but that mount is not
|
||||
# on the cluster yet (BLUEJAY-WS only). The tool gracefully returns a
|
||||
# "no DB found" message with rebuild instructions rather than crashing.
|
||||
- name: FLOWERCORE_INTRANET_URL
|
||||
value: "http://intranet-web.intranet.svc:5300"
|
||||
# Kubernetes
|
||||
- name: KUBERNETES_SERVICE_HOST
|
||||
value: "kubernetes.default.svc"
|
||||
@@ -196,6 +422,33 @@ spec:
|
||||
mountPath: /a0/work
|
||||
- name: knowledge
|
||||
mountPath: /a0/knowledge/custom/main
|
||||
- name: flowercore-extensions
|
||||
mountPath: /a0/extensions/flowercore
|
||||
readOnly: true
|
||||
- name: bluejay-theme
|
||||
mountPath: /a0/webui/static/css/custom
|
||||
readOnly: true
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
failureThreshold: 18
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- "curl -sf http://localhost:80/ > /dev/null && curl -sf --connect-timeout 3 http://fc-llm-bridge.fc-llm-bridge.svc:8080/healthz > /dev/null"
|
||||
periodSeconds: 30
|
||||
failureThreshold: 2
|
||||
resources:
|
||||
requests:
|
||||
memory: "2Gi"
|
||||
@@ -210,6 +463,27 @@ spec:
|
||||
- name: knowledge
|
||||
persistentVolumeClaim:
|
||||
claimName: agent-zero-knowledge
|
||||
- name: bluejay-tools-a
|
||||
configMap:
|
||||
name: bluejay-tools-a
|
||||
- name: bluejay-tools-b
|
||||
configMap:
|
||||
name: bluejay-tools-b
|
||||
- name: bluejay-tools-c
|
||||
configMap:
|
||||
name: bluejay-tools-c
|
||||
- name: bluejay-profile
|
||||
configMap:
|
||||
name: bluejay-profile
|
||||
- name: bluejay-prompts
|
||||
configMap:
|
||||
name: bluejay-prompts
|
||||
- name: flowercore-extensions
|
||||
configMap:
|
||||
name: flowercore-extensions
|
||||
- name: bluejay-theme
|
||||
configMap:
|
||||
name: bluejay-theme
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
@@ -292,6 +566,13 @@ spec:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
ports:
|
||||
- port: 80
|
||||
# Allow from monitoring (blackbox probe)
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: monitoring
|
||||
ports:
|
||||
- port: 80
|
||||
egress:
|
||||
# DNS
|
||||
- to:
|
||||
@@ -303,18 +584,59 @@ spec:
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# Ollama on edge1
|
||||
# Print.Web on edge2
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.57.15/32
|
||||
cidr: 10.0.57.16/32
|
||||
ports:
|
||||
- port: 11434
|
||||
- port: 5200
|
||||
# K8s API
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.56.11/32
|
||||
ports:
|
||||
- port: 6443
|
||||
# FlowerCore LLM Bridge (ADR-088 chat_model routing) — ClusterIP service
|
||||
# in the fc-llm-bridge namespace on port 8080.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: fc-llm-bridge
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
# FlowerCore.Chat MCP (Phase 0 pilot) — use the in-cluster chat-web
|
||||
# service instead of the public Traefik VIP so MCP traffic stays inside
|
||||
# the cluster and survives the private-range egress denylist.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: fc-chat
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
# FlowerCore.Knowledge MCP (Phase 1) — in-cluster direct route with
|
||||
# anonymous /healthz probe plus authenticated /mcp initialize/tool calls.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: knowledge
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
# Intranet search API — use in-cluster svc so traffic stays inside
|
||||
# the cluster and is not blocked by the private-range egress denylist.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: intranet
|
||||
ports:
|
||||
- port: 5300
|
||||
protocol: TCP
|
||||
# Allow internet (for kubectl image pull, etc)
|
||||
- to:
|
||||
- ipBlock:
|
||||
|
||||
16170
apps/agent-zero/configmaps-bluejay.yaml
Normal file
16170
apps/agent-zero/configmaps-bluejay.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -96,7 +96,9 @@ data:
|
||||
allow=ulaw
|
||||
allow=alaw
|
||||
direct_media=no
|
||||
dtmf_mode=inband
|
||||
; Yealink provisioning sends RFC2833/RFC4733 DTMF (payload 101).
|
||||
; Keep the PBX template aligned so physical desk phones emit ARI DTMF events.
|
||||
dtmf_mode=rfc4733
|
||||
rtp_symmetric=yes
|
||||
force_rport=yes
|
||||
rewrite_contact=yes
|
||||
@@ -173,6 +175,83 @@ data:
|
||||
remove_existing=yes
|
||||
qualify_frequency=60
|
||||
|
||||
; Test endpoints 901-904 for softphone proof
|
||||
[test-endpoint](!)
|
||||
type=endpoint
|
||||
context=from-internal
|
||||
transport=transport-udp
|
||||
disallow=all
|
||||
allow=ulaw
|
||||
allow=alaw
|
||||
direct_media=no
|
||||
rtp_symmetric=yes
|
||||
force_rport=yes
|
||||
rewrite_contact=yes
|
||||
|
||||
[901](test-endpoint)
|
||||
auth=auth901
|
||||
aors=901
|
||||
callerid="Proof Caller" <901>
|
||||
|
||||
[auth901]
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
username=901
|
||||
password=test-sip-secret-901
|
||||
|
||||
[901]
|
||||
type=aor
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
|
||||
[902](test-endpoint)
|
||||
auth=auth902
|
||||
aors=902
|
||||
callerid="Proof Callee" <902>
|
||||
|
||||
[auth902]
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
username=902
|
||||
password=test-sip-secret-901
|
||||
|
||||
[902]
|
||||
type=aor
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
|
||||
[903](test-endpoint)
|
||||
auth=auth903
|
||||
aors=903
|
||||
callerid="Proof Endpoint 3" <903>
|
||||
|
||||
[auth903]
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
username=903
|
||||
password=test-sip-secret-901
|
||||
|
||||
[903]
|
||||
type=aor
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
|
||||
[904](test-endpoint)
|
||||
auth=auth904
|
||||
aors=904
|
||||
callerid="Proof Endpoint 4" <904>
|
||||
|
||||
[auth904]
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
username=904
|
||||
password=test-sip-secret-901
|
||||
|
||||
[904]
|
||||
type=aor
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
|
||||
extensions.conf: |
|
||||
[general]
|
||||
static=yes
|
||||
@@ -195,6 +274,32 @@ data:
|
||||
exten => _1XX,1,Dial(PJSIP/${EXTEN},30)
|
||||
same => n,Hangup()
|
||||
|
||||
; Softphone proof endpoints and utility extensions
|
||||
exten => _9XX,1,NoOp(Proof call to ${EXTEN})
|
||||
same => n,Dial(PJSIP/${EXTEN},30)
|
||||
same => n,Hangup()
|
||||
|
||||
exten => 999,1,Answer()
|
||||
same => n,Playback(demo-echotest)
|
||||
same => n,Echo()
|
||||
same => n,Hangup()
|
||||
|
||||
exten => 998,1,Answer()
|
||||
same => n,Milliwatt()
|
||||
same => n,Hangup()
|
||||
|
||||
exten => 997,1,Answer()
|
||||
same => n,Wait(0.5)
|
||||
same => n,Playback(hello-world)
|
||||
same => n,Wait(1)
|
||||
same => n,Hangup()
|
||||
|
||||
exten => 996,1,Answer()
|
||||
same => n,Wait(0.5)
|
||||
same => n,Read(DIGITS,,4,,,5)
|
||||
same => n,SayDigits(${DIGITS})
|
||||
same => n,Hangup()
|
||||
|
||||
; Outbound via Twilio SIP trunk (11-digit US)
|
||||
exten => _1NXXNXXXXXX,1,Set(CALLERID(num)=+13202332529)
|
||||
same => n,Dial(PJSIP/+${EXTEN}@twilio-trunk,60)
|
||||
@@ -209,6 +314,13 @@ data:
|
||||
exten => *100,1,Stasis(flowercore-pbx,internal,ivr)
|
||||
same => n,Hangup()
|
||||
|
||||
; Test-only entry into the Victory Day workflow (DID +15074618329).
|
||||
; Used by live SIP AATs to exercise the VDAY Fun Menu + AsteriskGameHandler
|
||||
; path without dialing in over Twilio. Mnemonic: *832 = "V-D-A" (8-3-2).
|
||||
exten => *832,1,NoOp(Test entry: Victory Day workflow via AAT)
|
||||
same => n,Stasis(flowercore-pbx,inbound-pstn,+15074618329)
|
||||
same => n,Hangup()
|
||||
|
||||
; Star codes routed to FlowerCore Stasis app for handling
|
||||
exten => *0,1,Stasis(flowercore-pbx,starcode,*0)
|
||||
same => n,Hangup()
|
||||
|
||||
@@ -17,21 +17,50 @@ spec:
|
||||
labels:
|
||||
app: asterisk
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: rke2-agent1
|
||||
hostNetwork: true
|
||||
dnsPolicy: ClusterFirstWithHostNet
|
||||
securityContext:
|
||||
fsGroup: 0
|
||||
# CoreDNS in this cluster has an iamworkin.lan wildcard that catches
|
||||
# any unresolved name and returns 10.0.56.200 (Traefik VIP), which
|
||||
# means downloads.asterisk.org inside the pod resolves to Traefik and
|
||||
# returns 404. Pin the real address so the init container can fetch
|
||||
# the sounds tarball.
|
||||
hostAliases:
|
||||
- ip: 165.22.184.19
|
||||
hostnames:
|
||||
- downloads.asterisk.org
|
||||
initContainers:
|
||||
- name: install-sounds
|
||||
image: busybox:latest
|
||||
# Downloads Asterisk core sounds (en, ulaw) into the sounds emptyDir
|
||||
# volume so the base Asterisk image (which ships no sounds) can play
|
||||
# vm-advopts, vm-goodbye, digits/*, characters/*, beep, etc. Skips
|
||||
# the download if the directory already contains sound files —
|
||||
# re-running the pod after a hot image reload reuses the unpack.
|
||||
image: alpine:3.20
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
mkdir -p /sounds/en &&
|
||||
wget -qO- http://downloads.asterisk.org/pub/telephony/sounds/asterisk-core-sounds-en-ulaw-current.tar.gz | tar xz -C /sounds/en/ &&
|
||||
wget -qO- http://downloads.asterisk.org/pub/telephony/sounds/asterisk-extra-sounds-en-ulaw-current.tar.gz | tar xz -C /sounds/en/ &&
|
||||
echo "Sound files installed: $(find /sounds/en -type f | wc -l) files"
|
||||
set -eu
|
||||
if [ -f /sounds/en/vm-goodbye.ulaw ] || [ -f /sounds/en/vm-goodbye.gsm ]; then
|
||||
echo "Sounds already present — skipping download."
|
||||
exit 0
|
||||
fi
|
||||
echo "Installing curl + tar..."
|
||||
apk add --no-cache curl tar gzip >/dev/null
|
||||
cd /tmp
|
||||
echo "Downloading Asterisk core sounds (en, ulaw) 1.6.1..."
|
||||
# -k: cluster egress goes through a step-ca MITM for outbound TLS
|
||||
# that this pod does not trust. The tarball is a public artifact —
|
||||
# integrity is checked downstream by Asterisk at playback time.
|
||||
curl -fksSLO https://downloads.asterisk.org/pub/telephony/sounds/releases/asterisk-core-sounds-en-ulaw-1.6.1.tar.gz
|
||||
echo "Extracting to /sounds/en ..."
|
||||
mkdir -p /sounds/en
|
||||
tar -xzf asterisk-core-sounds-en-ulaw-1.6.1.tar.gz -C /sounds/en
|
||||
echo "Done — $(ls /sounds/en | wc -l) files installed."
|
||||
volumeMounts:
|
||||
- name: sounds
|
||||
mountPath: /sounds/en
|
||||
@@ -77,6 +106,11 @@ spec:
|
||||
mountPath: /var/log/asterisk
|
||||
- name: sounds
|
||||
mountPath: /var/lib/asterisk/sounds/en
|
||||
# Shared TTS audio — telephony-web writes .sln16 files here (as
|
||||
# /shared-tts), Asterisk plays them via `sound:tts/<name>` which
|
||||
# resolves to this mount. Both pods are pinned to rke2-agent1.
|
||||
- name: shared-tts
|
||||
mountPath: /var/lib/asterisk/sounds/tts
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
@@ -148,3 +182,7 @@ spec:
|
||||
emptyDir: {}
|
||||
- name: sounds
|
||||
emptyDir: {}
|
||||
- name: shared-tts
|
||||
hostPath:
|
||||
path: /tmp/tts-audio
|
||||
type: DirectoryOrCreate
|
||||
|
||||
106
apps/edge2-services/edge2-services.yaml
Normal file
106
apps/edge2-services/edge2-services.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
# edge2 Services — Traefik IngressRoutes for FlowerCore Print.Web on edge2
|
||||
# Proxies print.iamworkin.lan to edge2 (10.0.57.16:5200) via headless Service
|
||||
# + manual Endpoints (same K8s external-proxy pattern as noc-services).
|
||||
#
|
||||
# Print.Web has its own X-Api-Key authentication and exposes anonymous
|
||||
# endpoints for the bookmarklet / Python CLI / cups-notifier flow, so no
|
||||
# Traefik basicAuth middleware is wired here.
|
||||
#
|
||||
# ArgoCD managed - BlueJay Lab
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: edge2-proxy
|
||||
labels:
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
---
|
||||
# ============================================================
|
||||
# Print.Web - edge2:5200 (FlowerCore.Print.Web on Pi 4)
|
||||
# ============================================================
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: print-web-external
|
||||
namespace: edge2-proxy
|
||||
spec:
|
||||
ports:
|
||||
- port: 5200
|
||||
targetPort: 5200
|
||||
name: http
|
||||
clusterIP: None
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Endpoints
|
||||
metadata:
|
||||
name: print-web-external
|
||||
namespace: edge2-proxy
|
||||
subsets:
|
||||
- addresses:
|
||||
- ip: 10.0.57.16
|
||||
ports:
|
||||
- port: 5200
|
||||
name: http
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: print-web-tls
|
||||
namespace: edge2-proxy
|
||||
spec:
|
||||
secretName: print-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- print.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: print-web
|
||||
namespace: edge2-proxy
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- kind: Rule
|
||||
match: Host(`print.iamworkin.lan`)
|
||||
services:
|
||||
- name: print-web-external
|
||||
port: 5200
|
||||
tls:
|
||||
secretName: print-web-tls
|
||||
---
|
||||
# NetworkPolicy: allow Traefik ingress, allow egress to edge2 + DNS
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: edge2-proxy-netpol
|
||||
namespace: edge2-proxy
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
egress:
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.57.16/32
|
||||
ports:
|
||||
- port: 5200
|
||||
protocol: TCP
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
32
apps/fc-chat/fc-chat.yaml
Normal file
32
apps/fc-chat/fc-chat.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# FlowerCore Chat — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: chat-web-tls
|
||||
namespace: fc-chat
|
||||
spec:
|
||||
secretName: chat-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- chat.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: chat-web
|
||||
namespace: fc-chat
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`chat.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: chat-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: chat-web-tls
|
||||
40
apps/fc-desktop/fc-desktop.yaml
Normal file
40
apps/fc-desktop/fc-desktop.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
# FlowerCore Remote Desktop — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: remotedesktop-web-tls
|
||||
namespace: fc-desktop
|
||||
spec:
|
||||
secretName: remotedesktop-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- desktop.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: remotedesktop-web
|
||||
namespace: fc-desktop
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
# Host-level catch-all for desktop.iamworkin.lan. The /guacamole
|
||||
# path-prefix match lives in apps/guacamole/guacamole.yaml as a
|
||||
# separate IngressRoute in the guacamole namespace — the cluster
|
||||
# Traefik disallows cross-namespace service refs, so the PathPrefix
|
||||
# rule can't sit here. Traefik's router matching precedence gives
|
||||
# longer/more-specific rules priority automatically, so as long as
|
||||
# the guacamole IngressRoute exists it takes /guacamole traffic
|
||||
# before this catch-all sees it.
|
||||
- match: Host(`desktop.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: remotedesktop-web
|
||||
port: 8080
|
||||
tls:
|
||||
secretName: remotedesktop-web-tls
|
||||
105
apps/fc-distribution/README.md
Normal file
105
apps/fc-distribution/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# fc-distribution — staged deployment (Phase 1, USB provisioning)
|
||||
|
||||
**Status:** manifests staged, **NOT YET APPLIED**. Image must be built +
|
||||
imported and signing 1Password items confirmed before `git push`.
|
||||
|
||||
- Architecture: [`../../../FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md`](../../../FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md)
|
||||
- Repo: `D:\git\FlowerCore\FlowerCore.Distribution\` (`README.md`, `CLAUDE.md`)
|
||||
- Shared lib: `FlowerCore.Common` -> `FlowerCore.Shared.Distribution`
|
||||
|
||||
`FlowerCore.Distribution` publishes signed edition manifests (ECDSA P-256
|
||||
over canonical JSON) and serves the SHA-256 content-addressed blob store
|
||||
that USB builders pull from. The verifier embeds the `IAmWorkin ACME CA
|
||||
Root CA` as the trust anchor; per-edition leaf signing material lives in
|
||||
1Password and is mounted into the pod read-only.
|
||||
|
||||
## Deployment order (do NOT skip / reorder)
|
||||
|
||||
### 1. FlowerCore.DNS preflight — VERIFIED 2026-04-23
|
||||
|
||||
`dist.iamworkin.lan` already resolves to `10.0.56.200`, but keep the
|
||||
FlowerCore.DNS preflight green before push:
|
||||
|
||||
```bash
|
||||
curl -sk "https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight?hostname=dist.iamworkin.lan"
|
||||
# Expect: "resolvable": true
|
||||
|
||||
python bluejay-infra/scripts/check-pfsense-dns.py
|
||||
# Historical filename retained; implementation now calls FlowerCore.DNS
|
||||
# resolve-preflight instead of raw resolver lookups.
|
||||
```
|
||||
|
||||
If the record ever disappears, recreate it through FlowerCore.DNS before
|
||||
push/apply:
|
||||
|
||||
```bash
|
||||
curl -sk https://dns.iamworkin.lan/api/v1/servers
|
||||
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"dist","type":"A","data":"10.0.56.200","ttl":300}'
|
||||
```
|
||||
|
||||
If this is missing, cert-manager HTTP-01 will silently back off ~2h. See
|
||||
memory `feedback_pfsense_dns_required_for_acme.md`.
|
||||
|
||||
### 2. 1Password items required in vault `IAmWorkin`
|
||||
|
||||
| Item title | Item id | Used as |
|
||||
|---|---|---|
|
||||
| `FlowerCore Code Signing CA` | (existing) | Informational handle only — root CA is baked into the image at build time, not mounted |
|
||||
| `FlowerCore Edition Signing Key - edition:kiosk-standard` | `3hf33egdvnni6jyuws3r737mqe` | Mounted at `/signing/kiosk-standard/` |
|
||||
| `FlowerCore Edition Signing Key - edition:aistation-field` | `ccxrtsan5samfq4pfuczymacrq` | Mounted at `/signing/aistation-field/` |
|
||||
|
||||
Each edition item must publish three field labels (the operator turns
|
||||
field labels into Secret keys verbatim):
|
||||
|
||||
- `certificate.pem` — leaf certificate
|
||||
- `private-key.pem` — ECDSA P-256 private key
|
||||
- `chain.pem` — leaf + intermediate (referenced by the env var as the
|
||||
cert-path; the verifier uses this for signature path validation)
|
||||
|
||||
### 3. Build + import the image to rke2-server
|
||||
|
||||
The Pod is pinned to `rke2-server` because the Synology NFS export
|
||||
`/volume1/kubernetes` only allows that node. Importing to the agents is
|
||||
optional until the ACL is widened.
|
||||
|
||||
```bash
|
||||
# From BLUEJAY-WS, in D:\git\FlowerCore\FlowerCore.Distribution
|
||||
TAG="v$(date +%Y%m%d%H%M)"
|
||||
dotnet.exe publish -c Release -o deploy/app \
|
||||
src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
|
||||
podman build -t localhost/fc-distribution:$TAG -f deploy/Dockerfile.deploy deploy
|
||||
podman save localhost/fc-distribution:$TAG -o /tmp/fc-distribution.tar
|
||||
scp /tmp/fc-distribution.tar rke2-server:/tmp/
|
||||
ssh rke2-server "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-distribution.tar"
|
||||
```
|
||||
|
||||
### 4. Bump the image tag + push
|
||||
|
||||
Edit `fc-distribution.yaml`, replace `localhost/fc-distribution:v202604231530`
|
||||
with the tag from step 3, then:
|
||||
|
||||
```bash
|
||||
cd D:/git/FlowerCore/bluejay-infra
|
||||
python scripts/check-pfsense-dns.py
|
||||
git add apps/fc-distribution/
|
||||
git commit -m "feat(fc-distribution): deploy Phase 1 manifest publisher"
|
||||
git push
|
||||
```
|
||||
|
||||
ArgoCD picks up within ~3 minutes and creates `infra-fc-distribution`.
|
||||
|
||||
### 5. Verify
|
||||
|
||||
```bash
|
||||
fcadmin_ssh noc1 '
|
||||
kubectl -n argocd get application infra-fc-distribution
|
||||
kubectl -n fc-distribution get certificate,pod,secret
|
||||
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://dist.iamworkin.lan/healthz
|
||||
'
|
||||
```
|
||||
|
||||
Expect: Certificate `Ready: True` within ~60s, `/healthz` HTTP 200, both
|
||||
`edition-kiosk-standard` and `edition-aistation-field` Secrets present
|
||||
with `certificate.pem`, `private-key.pem`, `chain.pem` keys.
|
||||
347
apps/fc-distribution/fc-distribution.yaml
Normal file
347
apps/fc-distribution/fc-distribution.yaml
Normal file
@@ -0,0 +1,347 @@
|
||||
# FlowerCore.Distribution — edition manifest publisher + content-addressed blob store.
|
||||
# Phase 1 of the USB provisioning architecture: signed edition manifests
|
||||
# (ECDSA P-256 over canonical JSON) published per edition, plus a SHA-256
|
||||
# content-addressed blob store that USB builders pull from.
|
||||
#
|
||||
# Architecture: FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md
|
||||
# Repo: FlowerCore.Distribution/{README.md,CLAUDE.md}
|
||||
# Shared lib: FlowerCore.Common -> FlowerCore.Shared.Distribution
|
||||
# (manifest schema, canonical JSON, ECDSA P-256 sign/verify)
|
||||
#
|
||||
# Deployment order (see bluejay-infra/README.md and apps/fc-distribution/README.md):
|
||||
# 1. pfSense Unbound DNS override for dist.iamworkin.lan -> 10.0.56.200
|
||||
# (DONE 2026-04-23 — verify with `python bluejay-infra/scripts/check-pfsense-dns.py`).
|
||||
# 2. 1Password items must exist in vault `IAmWorkin`:
|
||||
# - `FlowerCore Code Signing CA` (informational)
|
||||
# - `FlowerCore Edition Signing Key - edition:kiosk-standard` (3hf33egdvnni6jyuws3r737mqe)
|
||||
# - `FlowerCore Edition Signing Key - edition:aistation-field` (ccxrtsan5samfq4pfuczymacrq)
|
||||
# Each edition item is expected to publish three field labels:
|
||||
# certificate.pem, private-key.pem, chain.pem
|
||||
# 3. Synology NFS export `/volume1/kubernetes` is currently restricted to
|
||||
# rke2-server (10.0.56.11). Pod is pinned via nodeSelector below. The
|
||||
# app writes to subPaths `distribution/data` and `distribution/blobs`.
|
||||
# 4. Build + import image: localhost/fc-distribution:v<YYYYMMDD><HHMM>
|
||||
# Import to rke2-server via `ctr images import` (NFS-pinned, no need
|
||||
# for the agents until ACL is widened — see guacamole pattern).
|
||||
# 5. Bump the image tag below and git push; ArgoCD ApplicationSet picks up
|
||||
# within ~3 minutes and creates `infra-fc-distribution`.
|
||||
#
|
||||
# NOTE on the root trust anchor:
|
||||
# The verifier needs an embedded root CA (`IAmWorkin ACME CA Root CA`).
|
||||
# That root is shipped INSIDE the published image (Phase 2 build step
|
||||
# bakes it into the bundle), NOT mounted from a Secret here. The
|
||||
# `codesigning-root-cert` OnePasswordItem below is informational only —
|
||||
# it gives operators a quick handle to the CA item from the cluster.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-distribution
|
||||
labels:
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
---
|
||||
# Informational handle to the FlowerCore Code Signing CA item in 1Password.
|
||||
# Not consumed by the pod at runtime — the root trust anchor is baked into
|
||||
# the published image. Operators can `kubectl -n fc-distribution get secret
|
||||
# codesigning-root-cert` to discover the CA item URL/admin handle.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: codesigning-root-cert
|
||||
namespace: fc-distribution
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FlowerCore Code Signing CA"
|
||||
---
|
||||
# Edition signing key + leaf cert + chain for edition:kiosk-standard.
|
||||
# 1Password item id: 3hf33egdvnni6jyuws3r737mqe
|
||||
# Operator syncs each field to a Secret key of the same name. Mounted
|
||||
# read-only at /signing/kiosk-standard inside the pod.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: edition-kiosk-standard
|
||||
namespace: fc-distribution
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FlowerCore Edition Signing Key - edition:kiosk-standard"
|
||||
---
|
||||
# Edition signing key + leaf cert + chain for edition:aistation-field.
|
||||
# 1Password item id: ccxrtsan5samfq4pfuczymacrq
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: edition-aistation-field
|
||||
namespace: fc-distribution
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FlowerCore Edition Signing Key - edition:aistation-field"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fc-distribution
|
||||
namespace: fc-distribution
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-distribution
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
# NFS-backed SQLite + blob store on a single node. Recreate avoids any
|
||||
# multi-attach overlap on the same NFS subPath during rollout.
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: fc-distribution
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-distribution
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
# Synology NFS export `/volume1/kubernetes` ACL only allows rke2-server
|
||||
# (10.0.56.11) right now. Until the ACL is widened in DSM (admin only),
|
||||
# this Pod must run on rke2-server or NFS mounts will be access-denied.
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: rke2-server
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
fsGroup: 1654
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: web
|
||||
# Placeholder tag — bump to the image you built + imported to
|
||||
# rke2-server before applying. Build with:
|
||||
# dotnet.exe publish -c Release -o deploy/app \
|
||||
# src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
|
||||
# podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy
|
||||
image: localhost/fc-distribution:v202604240010
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
env:
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:8080"
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
|
||||
value: "false"
|
||||
# SQLite connection (catalog + data-protection keys via FlowerCoreDbContext).
|
||||
# Read by Data/DatabaseProviderExtensions.cs in precedence order; Sqlite key wins.
|
||||
- name: FlowerCore__Database__Provider
|
||||
value: "Sqlite"
|
||||
- name: FlowerCore__Database__ConnectionStrings__Sqlite
|
||||
value: "Data Source=/data/distribution.db"
|
||||
# Content-addressed blob root (SHA-256 sharded on disk).
|
||||
# Bound by Services/NfsPvcBlobProvider.cs under FlowerCore:Distribution:Blobs.
|
||||
- name: FlowerCore__Distribution__Blobs__Root
|
||||
value: "/blobs"
|
||||
# Per-edition signing material — paths into the read-only
|
||||
# secret mounts below. Field labels in 1Password (and therefore
|
||||
# Secret key names) are: certificate.pem, private-key.pem, chain.pem
|
||||
- name: FlowerCore__Distribution__Signing__EditionCerts__kiosk-standard__CertPath
|
||||
value: "/signing/kiosk-standard/chain.pem"
|
||||
- name: FlowerCore__Distribution__Signing__EditionCerts__kiosk-standard__KeyPath
|
||||
value: "/signing/kiosk-standard/private-key.pem"
|
||||
- name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__CertPath
|
||||
value: "/signing/aistation-field/chain.pem"
|
||||
- name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__KeyPath
|
||||
value: "/signing/aistation-field/private-key.pem"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
# /healthz is exposed by the scaffold (StartupGateMiddleware-aware).
|
||||
# Liveness uses tcpSocket as a cheap fallback in case a future
|
||||
# middleware change accidentally gates /healthz behind auth
|
||||
# (memory: 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: sqlite
|
||||
mountPath: /data
|
||||
subPath: distribution/data
|
||||
- name: blobs
|
||||
mountPath: /blobs
|
||||
subPath: distribution/blobs
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
- name: kiosk-standard
|
||||
mountPath: /signing/kiosk-standard
|
||||
readOnly: true
|
||||
- name: aistation-field
|
||||
mountPath: /signing/aistation-field
|
||||
readOnly: true
|
||||
volumes:
|
||||
# Synology NFS at /volume1/kubernetes — same export pattern as
|
||||
# apps/guacamole/guacamole.yaml (recordings volume). Pinned by
|
||||
# ACL to rke2-server. Never mount the subpath as nfs.path —
|
||||
# always mount the export root and use volumeMount.subPath.
|
||||
- name: sqlite
|
||||
nfs:
|
||||
server: 10.0.58.3
|
||||
path: /volume1/kubernetes
|
||||
- name: blobs
|
||||
nfs:
|
||||
server: 10.0.58.3
|
||||
path: /volume1/kubernetes
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
- name: kiosk-standard
|
||||
secret:
|
||||
secretName: edition-kiosk-standard
|
||||
defaultMode: 0400
|
||||
- name: aistation-field
|
||||
secret:
|
||||
secretName: edition-aistation-field
|
||||
defaultMode: 0400
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fc-distribution
|
||||
namespace: fc-distribution
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-distribution
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: fc-distribution
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: fc-distribution-tls
|
||||
namespace: fc-distribution
|
||||
spec:
|
||||
secretName: fc-distribution-tls-secret
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- dist.iamworkin.lan
|
||||
duration: 2160h # 90d
|
||||
renewBefore: 720h # 30d
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: fc-distribution
|
||||
namespace: fc-distribution
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`dist.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: fc-distribution
|
||||
port: 80
|
||||
tls:
|
||||
secretName: fc-distribution-tls-secret
|
||||
---
|
||||
# === dist.flowercore.io public surface (2026-04-24) =========================
|
||||
#
|
||||
# Shares the Deployment + Service + PVC with the internal IngressRoute above.
|
||||
# The controller's NamedEntitlementResolverRouter picks between the internal
|
||||
# (permissive) and public (strict) StaticTokenEntitlementResolver based on
|
||||
# the X-FC-Distribution-Profile header — which the middleware below injects
|
||||
# on every public-host request after stripping any caller-supplied value.
|
||||
#
|
||||
# Cert is the shared Cloudflare Origin Certificate for *.flowercore.io, literal
|
||||
# bytes copied (matches gitea-public, matrix, telephony, mail, flowercore-landing
|
||||
# pattern — not yet via OnePasswordItem operator).
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: cf-origin-flowercore-io
|
||||
namespace: fc-distribution
|
||||
type: kubernetes.io/tls
|
||||
data:
|
||||
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVvRENDQTRpZ0F3SUJBZ0lVSXN4c1NKV1VRL0tqZ09ldk81YnNuVi9rZVE4d0RRWUpLb1pJaHZjTkFRRUwKQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJrd0Z3WURWUVFLRXhCRGJHOTFaRVpzWVhKbExDQkpibU11TVRRdwpNZ1lEVlFRTEV5dERiRzkxWkVac1lYSmxJRTl5YVdkcGJpQlRVMHdnUTJWeWRHbG1hV05oZEdVZ1FYVjBhRzl5CmFYUjVNUll3RkFZRFZRUUhFdzFUWVc0Z1JuSmhibU5wYzJOdk1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGgKTUI0WERUSTJNRE14TURFMk16TXdNRm9YRFRReE1ETXdOakUyTXpNd01Gb3dZakVaTUJjR0ExVUVDaE1RUTJ4dgpkV1JHYkdGeVpTd2dTVzVqTGpFZE1Cc0dBMVVFQ3hNVVEyeHZkV1JHYkdGeVpTQlBjbWxuYVc0Z1EwRXhKakFrCkJnTlZCQU1USFVOc2IzVmtSbXhoY21VZ1QzSnBaMmx1SUVObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUcKOXcwQkFRRUZBQU9DQVE0QU1JSUJDZ0tDQVFFQXV0QmpkQ0xEdHdMQlZCU0Y1ZU1OMkt3ckIxTmZmRVhRMjlRRAo1aVR0dzJFcEZXNVJJSllkMjNrYUpCMU5jZXpHWlg4a0Q0cGEyWHpFZW1MVEtJNWw0MU11b3FoWjczNVE3U3RWCkVjRFFTT2ZYTkZQdFMwb0hqb0pRdGF2QjM0ZmJNR3l4Mmx0MU9HUzRNMGtLUWpBNWR6OTJQYjNyZ1RKR0JhOW4KeTZtVThncjRuUHRSdklxZ3NxdjRtMFA3dVU1YjE3NzU1Y2JLSDVoMzIxWHVjMDU4Tzl4M2JHQ0NuRUJXWDdqeApjRGhkUEs1Ri9XRjVBQnl5cFhIQ0ZxUUd4M1NVbmtCQ0ZQSmRabnMra3BHVUZWZGhud3B6NjBtNnlJSzQ0eVR4CjZqR3JOTFEyM1dOK2gwU1lCZU5vb2JBWThydkpiVlZEaGJqSVhBTWtFNGQzVll1TlhRSURBUUFCbzRJQklqQ0MKQVI0d0RnWURWUjBQQVFIL0JBUURBZ1dnTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjRApBVEFNQmdOVkhSTUJBZjhFQWpBQU1CMEdBMVVkRGdRV0JCUkt1NkJVUDZ0N2dpbFRPay9FdEdKQ3R6N3dTREFmCkJnTlZIU01FR0RBV2dCUWs2Rk5YWFh3MFFJZXA2NVRidXVFV2VQd3BwREJBQmdnckJnRUZCUWNCQVFRME1ESXcKTUFZSUt3WUJCUVVITUFHR0pHaDBkSEE2THk5dlkzTndMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5agpZVEFqQmdOVkhSRUVIREFhZ2d3cUxtbGhiWGR2Y21zdWFXNkNDbWxoYlhkdmNtc3VhVzR3T0FZRFZSMGZCREV3Ckx6QXRvQ3VnS1lZbmFIUjBjRG92TDJOeWJDNWpiRzkxWkdac1lYSmxMbU52YlM5dmNtbG5hVzVmWTJFdVkzSnMKTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFDSjMvTGNleE5pb0lWdUxoemhmbTZCeDV2SWk3T25CaHF1WUlDdwplNnArZ0prdE16ZFJQcDV0bk03dllBWmxMajVJOTByWDRuczhJc3dEbzJBN2wwYTRGZVJFclFmRklsZXQzbjIyCjUxVTZYVElCSks5c1FZT0FkU3pJUzV1OUNKSFpBUTF5WmxSd3BBR3RVWnhxL1dpcGFWUTRwNXhrcEJNMVlZSlAKNW1jQ09HcFErSnpORlpQc2daYUJncDBYL1BBZkNJRkkyZld5QWE2elBqRm0rdDVXUXIrZlBaT2VUS2VIbWVzVgo3UlZxUUdEb3Q0eTY1NklEdmdmU2ZLRnFIRW9XNDJVbDBxQ05hMS9keEJld3NIS1VWWE1ETkdiQlNVQjM4TG9YCm1OQ3hJQlVOUjR0TG1CQUxZT3hVMnZhSWRCd0xBc2YrcndnVnVjUGpCUTc2VWMwUQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
|
||||
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQzYwR04wSXNPM0FzRlUKRklYbDR3M1lyQ3NIYTE5OFJkRGIxQVBtSk8zRFlTa1ZibEVnbGgzYmVSb2tIVTF4N01abGZ5UVBpbHJaZk1SNgpZdE1vam1YalV5NmlxRm52ZmxEdEsxVVJ3TkJJNTljMFUrMUxTZ2VPZ2xDMXE4SGZoOXN3YkxIYVczVTRaTGd6ClNRcENNRGwzUDNZOXZldUJNa1lGcjJmTHFaVHlDdmljKzFHOGlxQ3lxL2liUS91NVRsdlh2dm5seHNvZm1IZmIKVmU1elRudzczSGRzWUlLY1FGWmZ1UEZ3T0YwOHJrWDlZWGtBSExLbGNjSVdwQWJIZEpTZVFFSVU4bDFtZXo2UwprWlFWVjJHZkNuUHJTYnJJZ3JqakpQSHFNYXMwdERiZFkzNkhSSmdGNDJpaHNCanl1OGx0VlVPRnVNaGNBeVFUCmgzZFZpNDFkQWdNQkFBRUNnZ0VBTGlseXZkNmVTcEYvZUxtV2lhTVV4NUxwa2dhWHpITkxCQnNNZUpqcytLL0EKVVdlZ1crTkVUdmlLalZ5QlI5SzRocG1IYldDa2lPUDBBQUwrQnlKQ3lvekNOQmJTSEdRejlwc1R5dzZBV1ZlUwpuYjlVWGx1VmFQRktKTTRqbXNydERuYjVic25WT2lGblErTDdTalkwNlFMUlFybjBvUWp0ZFJldUdBMFlQVU90CkhSYzNsMFg2ZHJqdkJYY2prWTQwWm9ZYkRrelJnU1JWbWVOUGFIbjZPR0NtYUVUMXVyK01qYVZ2ME9lbEdIWncKVzljSEIxaHNxRzUvMWU3V0RQN0l0cjkwTmg4ay81NVhiK3lQUnhsRFd5bWtZMzIvdFBtZzdESTRKV2tRRWt3cgpIZUtwODVTcE5ta1liRnVpVFppeU8zZDZ0aXZHNHhFZW8rSzFVVFU4c1FLQmdRRFRNSEU1RDFYVC9HbGR5VHNsCllrODRVL1N0NXUrK2RIUEt1Wmw2dVB0UGgxV1lrdnFRcmdrL05YanVud2xGN0Y3b2tWOGdPeWxreTYwYTZkcXIKeXZwN1ZJdXYzekVlc2h2NjNWMlpaVkMzcXZYSzFheit3Zmx3NitCZmVuRlY5S2NENHN0dTdwOFRPWmFGN01CUgo3YXZzaXVXbWtqdmM1TlVLRmVDRTY0SnZFUUtCZ1FEaWMrbWlNLzBodDN1ajhuOXgyMDFQZFNqbEpVaUc1NjNNCnRYZlBCdDJRT0NhaVluUFNFdTdXdm5pQWRFL2xrMm91cFRWam9LYmZPbDFyQjd6UzVhc2kxdVdDZDhlUy9UWGIKdU5iRmlNMDB4L3JxalMydCtQbTd4MVhrYTB4TFNSRDNmZ0tSQldSN3pscStkYWZ1WE1qelUxRnh5dTIycGphRgpIMEl3NEpCUmpRS0JnUUNOaWhMb0Rob1V5RCtKNXJzb00vb3FJMEtDWnB0WlJzendHbkg5cVFwdFk2Ti9iVXBYCk92emhpeUh3czAvUXVEbG5uejVrNktHMmR6Y2VLWXN2eGdzWUt6S3ZmV043VWgya2hVWWM3NlVvWTREMkh6MGgKUkxtNzc2cGg4enNRUTdiSHlQRlUrTUpPYlRNdnNOdTRUUlVEcEplRGl0QnFIRWVYeWMrKzVlUjJNUUtCZ0h2UgptVHVoWlpVYitEVEtrVGkyQ20yWnlBU1RBRGNUVW9xTjVyYUNNSDk4MUZNUnRmWjFkN1pmYXhBQmlQWWtSbmkrCnlKUnk4UXM1cEg2ek9tR3VSb2JFTGJYS3ZJcjRmSXhwWXJXYmVXaVV0L09yd2dCUUZHekNMNHEzeUgyWnMvYy8KSlRRYVdMa0JPY2pPR0VaUzRXVjZkeHZiTTJNZE9zNUxLeXdDZmFhNUFvR0FIQUE1eEN0dndOZE4xeExndkZ3RApPK2lyMDl1bXMxOFBzSVpmK1ZrWGtpcHF4MWNUT0hEanpPR01yWXV0M2FFeE00Zjd2ckFHRFMyY2pwZjM0T1JxCit4Y2gwWlNaQ2FDZmlnZG9OelNkcDFLcmo0cnFKdG5ZdS9CNDlDQlVoSDBNaCtSRWswQ0hHOVE4b3FOWFk0V0wKbVVOVTZMYUkwQWtvSzNVb2tWQVJEYXM9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
|
||||
---
|
||||
# Traefik middleware: strips any caller-supplied X-FC-Distribution-Profile,
|
||||
# then sets an authoritative 'public' value so the controller routes to the
|
||||
# strict entitlement resolver. The trust boundary is this middleware — the
|
||||
# internal IngressRoute (dist.iamworkin.lan) does NOT attach it.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: dist-public-profile-header
|
||||
namespace: fc-distribution
|
||||
spec:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-FC-Distribution-Profile: "public"
|
||||
---
|
||||
# Public IngressRoute: binds dist.flowercore.io (Cloudflare-proxied A record
|
||||
# -> pfSense NAT -> Traefik VIP 10.0.56.200) to the same backend Service that
|
||||
# serves dist.iamworkin.lan. Header-injection middleware ensures the
|
||||
# controller uses the public (strict) entitlement resolver.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: fc-distribution-public
|
||||
namespace: fc-distribution
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
# Method allowlist: Host + (GET || HEAD). Anything else misses every
|
||||
# route and Traefik returns 404 before reaching the pod — edge-level
|
||||
# defense-in-depth over the controller's strict-mode entitlement check.
|
||||
# Together these block admin ops (POST /blobs, POST /manifests*) from
|
||||
# ever being processed on the public surface.
|
||||
- match: Host(`dist.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: dist-public-profile-header
|
||||
services:
|
||||
- name: fc-distribution
|
||||
port: 80
|
||||
tls:
|
||||
secretName: cf-origin-flowercore-io
|
||||
9
apps/fc-distribution/kustomization.yaml
Normal file
9
apps/fc-distribution/kustomization.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
# ArgoCD's bluejay-infra ApplicationSet uses a directory generator and does
|
||||
# not require kustomization.yaml (existing apps like fc-llm-bridge and
|
||||
# guacamole have none). This file is included anyway as a single source of
|
||||
# truth for the resource list and to make `kubectl kustomize` previews work
|
||||
# from a working copy.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- fc-distribution.yaml
|
||||
32
apps/fc-dms/fc-dms.yaml
Normal file
32
apps/fc-dms/fc-dms.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# FlowerCore DMS — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: dms-web-tls
|
||||
namespace: fc-dms
|
||||
spec:
|
||||
secretName: dms-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- dms.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: dms-web
|
||||
namespace: fc-dms
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`dms.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: dms-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: dms-web-tls
|
||||
@@ -256,6 +256,20 @@ spec:
|
||||
targetPort: 80
|
||||
name: http
|
||||
---
|
||||
# TLS Certificate for internal LAN access
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: fc-landing-tls
|
||||
namespace: fc-system
|
||||
spec:
|
||||
secretName: fc-landing-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- flowercore.iamworkin.lan
|
||||
---
|
||||
# Internal IngressRoute (LAN access)
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
@@ -271,7 +285,8 @@ spec:
|
||||
services:
|
||||
- name: fc-landing
|
||||
port: 80
|
||||
tls: {}
|
||||
tls:
|
||||
secretName: fc-landing-tls
|
||||
---
|
||||
# Public IngressRoute (flowercore.io with Cloudflare origin cert)
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
|
||||
174
apps/fc-llm-bridge/README.md
Normal file
174
apps/fc-llm-bridge/README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# fc-llm-bridge — staged deployment (ADR-088)
|
||||
|
||||
**Status:** manifests staged, **NOT YET APPLIED**. Do not `git push` or sync
|
||||
ArgoCD until the two pre-requisites below are done, in order.
|
||||
|
||||
Design: [`../../../FlowerCore.Notes/docs/ai-agents/agent-zero-anthropic-bridge.md`](../../../FlowerCore.Notes/docs/ai-agents/agent-zero-anthropic-bridge.md)
|
||||
ADR: ADR-088 in [`../../../FlowerCore.Notes/ARCHITECTURE.md`](../../../FlowerCore.Notes/ARCHITECTURE.md)
|
||||
|
||||
## Deployment order (do NOT skip / reorder)
|
||||
|
||||
### 1. FlowerCore.DNS preflight — REQUIRED FIRST
|
||||
|
||||
`fc-llm-bridge.iamworkin.lan` must keep resolving to `10.0.56.200` through
|
||||
FlowerCore.DNS before this manifest is applied.
|
||||
|
||||
step-ca (the ACME CA on noc1) uses pfSense Unbound (10.0.56.1), **not**
|
||||
cluster CoreDNS. If you apply this manifest before adding the DNS override,
|
||||
cert-manager's HTTP-01 challenge silently fails for ~2h (exponential backoff)
|
||||
until someone manually runs `kubectl -n fc-llm-bridge delete order <order>`
|
||||
to bust the cache. See memory `feedback_pfsense_dns_required_for_acme.md`.
|
||||
|
||||
Verify the record through the public preflight API:
|
||||
|
||||
```bash
|
||||
curl -sk "https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight?hostname=fc-llm-bridge.iamworkin.lan"
|
||||
# Expect: "resolvable": true
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
python scripts/check-pfsense-dns.py
|
||||
# Historical filename retained; implementation now calls FlowerCore.DNS
|
||||
# resolve-preflight instead of raw resolver lookups.
|
||||
```
|
||||
|
||||
If the record is missing, recreate it through FlowerCore.DNS before pushing:
|
||||
|
||||
```bash
|
||||
curl -sk https://dns.iamworkin.lan/api/v1/servers
|
||||
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"fc-llm-bridge","type":"A","data":"10.0.56.200","ttl":300}'
|
||||
```
|
||||
|
||||
### 2. Create the `FC LLM Bridge API Keys` 1Password item
|
||||
|
||||
The `Claude API Key` item in vault `IAmWorkin` already exists (id
|
||||
`e5tth3y5mp3lhdavg35pxadzca`, see `docs/ai-agents/anthropic-integration.md`).
|
||||
|
||||
The new item for per-consumer bridge API keys does NOT yet exist. Create it
|
||||
before the first apply of this manifest — the Deployment marks the individual
|
||||
key env vars `optional: true` so missing keys will not crash the pod, but the
|
||||
bridge will reject every request with 401 until at least one key is populated.
|
||||
|
||||
| Field | Item position | Type | Purpose |
|
||||
|-------|---------------|------|---------|
|
||||
| `credential` | Top section | Password (random, 48 char) | Unused placeholder required by the 1Password schema for single-field items. Can be anything — this file is never read by K8s. |
|
||||
| `agent-zero-ws` | "API Keys" section | Password (random, 48 char) | API key for the BLUEJAY-WS Agent Zero instance. |
|
||||
| `agent-zero-k8s` | "API Keys" section | Password (random, 48 char) | API key for the K8s-hosted `agent-zero` Deployment. |
|
||||
| `spare-1` | "API Keys" section | Password (random, 48 char) | Reserve for future Agent Zero forks / smoke-test scripts. |
|
||||
| `spare-2` | "API Keys" section | Password (random, 48 char) | Reserve. |
|
||||
|
||||
Steps via the CLI (run from a machine with `op` signed in):
|
||||
|
||||
```bash
|
||||
op item create \
|
||||
--category="API Credential" \
|
||||
--title="FC LLM Bridge API Keys" \
|
||||
--vault="IAmWorkin" \
|
||||
"API Keys.agent-zero-ws[password]=$(openssl rand -hex 24)" \
|
||||
"API Keys.agent-zero-k8s[password]=$(openssl rand -hex 24)" \
|
||||
"API Keys.spare-1[password]=$(openssl rand -hex 24)" \
|
||||
"API Keys.spare-2[password]=$(openssl rand -hex 24)"
|
||||
```
|
||||
|
||||
OR via the 1Password GUI — create a new item titled exactly `FC LLM Bridge API
|
||||
Keys` in the `IAmWorkin` vault, add an `API Keys` section, add four password
|
||||
fields named `agent-zero-ws`, `agent-zero-k8s`, `spare-1`, `spare-2` with
|
||||
`openssl rand -hex 24` values.
|
||||
|
||||
**Mapping to K8s:** The 1Password Connect operator syncs each field to a
|
||||
Secret key of the same name. The Deployment's env vars
|
||||
(`FlowerCore__LlmBridge__ApiKeys__agent-zero-ws` etc) reference those Secret
|
||||
keys. In `FlowerCore.Shared.Api.Authentication.ApiKeyAuthMiddleware`, the key
|
||||
name (e.g. `agent-zero-k8s`) becomes the `fc.app` claim on the
|
||||
`ClaimsPrincipal`, which is what `IBudgetLedger` uses to scope spend per
|
||||
consumer.
|
||||
|
||||
### 3. Build + import the image to every RKE2 node
|
||||
|
||||
```bash
|
||||
# From BLUEJAY-WS, in D:\git\FlowerCore\FlowerCore.LlmBridge
|
||||
TAG="v$(date +%Y%m%d%H%M%S)"
|
||||
dotnet.exe publish -c Release -o deploy/app \
|
||||
src/FlowerCore.LlmBridge.Web/FlowerCore.LlmBridge.Web.csproj
|
||||
podman build -t localhost/fc-llm-bridge:$TAG -f deploy/Dockerfile.deploy deploy
|
||||
podman save localhost/fc-llm-bridge:$TAG -o /tmp/fc-llm-bridge.tar
|
||||
|
||||
# SCP to each node and ctr import
|
||||
for NODE in rke2-server rke2-agent1 rke2-agent2; do
|
||||
scp /tmp/fc-llm-bridge.tar $NODE:/tmp/
|
||||
ssh $NODE "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-llm-bridge.tar"
|
||||
done
|
||||
```
|
||||
|
||||
### 4. Bump the image tag in the manifest
|
||||
|
||||
Edit `fc-llm-bridge.yaml`, replace `localhost/fc-llm-bridge:v00000000000000`
|
||||
with the tag from step 3.
|
||||
|
||||
### 5. Commit + push
|
||||
|
||||
```bash
|
||||
cd D:/git/FlowerCore/bluejay-infra
|
||||
# re-run the DNS gate
|
||||
python scripts/check-pfsense-dns.py
|
||||
git add apps/fc-llm-bridge/
|
||||
git commit -m "feat(fc-llm-bridge): deploy ADR-088 Agent Zero bridge"
|
||||
git push
|
||||
```
|
||||
|
||||
ArgoCD picks up within ~3 minutes and creates `infra-fc-llm-bridge`.
|
||||
|
||||
### 6. Verify
|
||||
|
||||
```bash
|
||||
# From noc1
|
||||
fcadmin_ssh noc1 '
|
||||
kubectl -n argocd get application infra-fc-llm-bridge
|
||||
kubectl -n fc-llm-bridge get certificate,pod
|
||||
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://fc-llm-bridge.iamworkin.lan/healthz
|
||||
'
|
||||
```
|
||||
|
||||
Expect: Certificate `Ready: True` within ~60s, `/healthz` HTTP 200.
|
||||
|
||||
### 7. Flip Agent Zero to the bridge
|
||||
|
||||
After the bridge passes a real chat smoke test, update the Agent Zero
|
||||
ConfigMap (`apps/agent-zero/agent-zero.yaml`) to route through the bridge:
|
||||
|
||||
- `A0_SET_chat_model_api_base` / `config.json > chat_model.api_base`
|
||||
-> `https://fc-llm-bridge.iamworkin.lan/v1`
|
||||
- Add an `A0_SET_chat_model_api_key` env var wired to a K8s Secret sourced
|
||||
from `FC LLM Bridge API Keys` field `agent-zero-k8s`.
|
||||
- Set `chat_model.name` to `fc:balanced` (or a concrete model) — the bridge
|
||||
accepts both tier aliases and concrete model names.
|
||||
|
||||
Do the same for BLUEJAY-WS Agent Zero (`agent-zero-ws` key), or keep the
|
||||
workstation on direct Ollama and only route Anthropic calls through the
|
||||
bridge (the design doc describes this split as the preferred approach).
|
||||
|
||||
## Current state at staging time (2026-04-23)
|
||||
|
||||
- `fc-llm-bridge.iamworkin.lan` — public FlowerCore.DNS preflight is now
|
||||
green and resolves to `10.0.56.200`; keep `python scripts/check-pfsense-dns.py`
|
||||
green before push.
|
||||
- `FC LLM Bridge API Keys` — NOT created in 1Password (user action).
|
||||
- `Claude API Key` — already exists in `IAmWorkin` vault
|
||||
(`e5tth3y5mp3lhdavg35pxadzca`), also consumed by AiStation and Chat.Web.
|
||||
- `localhost/fc-llm-bridge:v*` image — not yet built; `FlowerCore.LlmBridge`
|
||||
repo has local commit `6d285b5` only, no remote.
|
||||
- ArgoCD `infra-fc-llm-bridge` Application — will be auto-created by the
|
||||
`bluejay-infra` ApplicationSet once the directory is on `main`.
|
||||
|
||||
## Why tcpSocket probes (not `/healthz`)
|
||||
|
||||
The bridge runs `ApiKeyAuthMiddleware`. `/healthz` and `/health` are exempt
|
||||
via `FlowerCore:LlmBridge:AuthExemptPaths`, so an HTTP probe would work
|
||||
today. But a future change to the middleware registration order could
|
||||
silently turn kubelet probes into 401/404, which crashes pods on every
|
||||
deploy. `tcpSocket` keeps probes robust against that regression. Memory:
|
||||
`feedback_k8s_probes_behind_auth_middleware.md`.
|
||||
280
apps/fc-llm-bridge/fc-llm-bridge.yaml
Normal file
280
apps/fc-llm-bridge/fc-llm-bridge.yaml
Normal file
@@ -0,0 +1,280 @@
|
||||
# FlowerCore.LlmBridge — OpenAI-compatible bridge for Agent Zero.
|
||||
# Routes through FlowerCore.Shared.Chat (ILlmProviderClient) with budget
|
||||
# enforcement, response caching, and tier-based model routing. Lets Agent
|
||||
# Zero (Python) reach Anthropic and Ollama providers without re-implementing
|
||||
# the C# budget/cache/router primitives.
|
||||
#
|
||||
# Design: FlowerCore.Notes/docs/ai-agents/agent-zero-anthropic-bridge.md
|
||||
# ADR: FlowerCore.Notes/ARCHITECTURE.md (ADR-088)
|
||||
#
|
||||
# Deployment order (see bluejay-infra/README.md):
|
||||
# 1. pfSense DNS override for fc-llm-bridge.iamworkin.lan -> 10.0.56.200
|
||||
# (REQUIRED before this is applied — cert-manager HTTP-01 will silently
|
||||
# fail for ~2h backoff otherwise). Run scripts/pfsense-add-dns-overrides.py.
|
||||
# 2. 1Password items `Claude API Key` (already exists) and
|
||||
# `FC LLM Bridge API Keys` (create when first non-dev environment comes up).
|
||||
# 3. Build + import image: localhost/fc-llm-bridge:v<YYYYMMDD><HHMM>
|
||||
# Import to rke2-server, rke2-agent1, rke2-agent2 via ctr images import.
|
||||
# 4. Bump the image tag below and git push; ArgoCD ApplicationSet picks up.
|
||||
# 5. Flip Agent Zero chat.openai.base_url to https://fc-llm-bridge.iamworkin.lan/v1
|
||||
# and api_key to the op://IAmWorkin/FC LLM Bridge API Keys/agent-zero-k8s value.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-llm-bridge
|
||||
labels:
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
---
|
||||
# Claude (Anthropic) API key — shared across FC services.
|
||||
# Existing 1Password item. `credential` field -> Secret `anthropic-api-key`.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: anthropic-api-key
|
||||
namespace: fc-llm-bridge
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/Claude API Key"
|
||||
---
|
||||
# Per-consumer API keys for the bridge itself.
|
||||
# NEW 1Password item — see apps/fc-llm-bridge/README.md for the field layout
|
||||
# to create before first apply. Fields become Secret keys of the same name:
|
||||
# agent-zero-ws, agent-zero-k8s, spare-1, spare-2
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: fc-llm-bridge-api-keys
|
||||
namespace: fc-llm-bridge
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FC LLM Bridge API Keys"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: fc-llm-bridge-data
|
||||
namespace: fc-llm-bridge
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fc-llm-bridge
|
||||
namespace: fc-llm-bridge
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-llm-bridge
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: fc-llm-bridge
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-llm-bridge
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: web
|
||||
# Placeholder tag — bump to the image you built + imported to every
|
||||
# RKE2 node before applying. Build with:
|
||||
# dotnet.exe publish -c Release -o deploy/app \
|
||||
# src/FlowerCore.LlmBridge.Web/FlowerCore.LlmBridge.Web.csproj
|
||||
# podman build -t localhost/fc-llm-bridge:v<tag> -f deploy/Dockerfile.deploy deploy
|
||||
image: localhost/fc-llm-bridge:v202604300022
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
env:
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:8080"
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
|
||||
value: "false"
|
||||
# SQLite (budget ledger + response cache + data-protection keys)
|
||||
- name: FlowerCore__LlmBridge__SqliteConnectionString
|
||||
value: "Data Source=/data/llm-bridge.db"
|
||||
- name: FlowerCore__LlmBridge__DefaultTenantId
|
||||
value: "default"
|
||||
- name: FlowerCore__LlmBridge__DefaultAppName
|
||||
value: "agent-zero"
|
||||
- name: FlowerCore__LlmBridge__UtilModel
|
||||
value: "qwen2.5:1.5b"
|
||||
- name: FlowerCore__LlmBridge__EmbedModel
|
||||
value: "nomic-embed-text"
|
||||
# Per-consumer API keys — from OnePasswordItem fc-llm-bridge-api-keys.
|
||||
# Each field becomes a Secret key of the same name. The key-name
|
||||
# lands in the auth principal's `fc.app` claim for ledger scoping.
|
||||
- name: FlowerCore__LlmBridge__ApiKeys__agent-zero-ws
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: agent-zero-ws
|
||||
optional: true
|
||||
- name: FlowerCore__LlmBridge__ApiKeys__agent-zero-k8s
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: agent-zero-k8s
|
||||
optional: true
|
||||
- name: FlowerCore__LlmBridge__ApiKeys__spare-1
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: spare-1
|
||||
optional: true
|
||||
- name: FlowerCore__LlmBridge__ApiKeys__spare-2
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-llm-bridge-api-keys
|
||||
key: spare-2
|
||||
optional: true
|
||||
# Shared.Chat — Ollama (edge1 Pi 5 + AI HAT+, matches bridge default)
|
||||
- name: FlowerCore__Chat__OllamaBaseUrl
|
||||
value: "http://10.0.57.17:11434"
|
||||
- name: FlowerCore__Chat__HttpTimeout
|
||||
value: "00:05:00"
|
||||
# Shared.Chat — Anthropic
|
||||
- name: FlowerCore__Chat__Anthropic__Enabled
|
||||
value: "true"
|
||||
- name: FlowerCore__Chat__Anthropic__ApiKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: anthropic-api-key
|
||||
key: password
|
||||
- name: FlowerCore__Chat__Anthropic__OrganizationId
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: anthropic-api-key
|
||||
key: organization_id
|
||||
optional: true
|
||||
- name: FlowerCore__Chat__Anthropic__BaseUrl
|
||||
value: "https://api.anthropic.com"
|
||||
- name: FlowerCore__Chat__Anthropic__DefaultModel
|
||||
value: "claude-sonnet-4-6"
|
||||
- name: FlowerCore__Chat__Anthropic__AnthropicVersion
|
||||
value: "2023-06-01"
|
||||
- name: FlowerCore__Chat__Anthropic__Timeout
|
||||
value: "00:05:00"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 768Mi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: app-data
|
||||
mountPath: /app/data
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1654
|
||||
runAsGroup: 1654
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# tcpSocket probes: the app runs ApiKeyAuthMiddleware. /healthz is
|
||||
# registered as anonymous via AuthExemptPaths but tcpSocket avoids any
|
||||
# future accidental middleware ordering regression
|
||||
# (memory: feedback_k8s_probes_behind_auth_middleware).
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
# Lower ndots so external FQDNs like api.anthropic.com are tried BEFORE
|
||||
# the ndots:5 default expands them through the cluster search path, which
|
||||
# includes iamworkin.lan. CoreDNS has a `template IN A iamworkin.lan`
|
||||
# wildcard that answers `api.anthropic.com.iamworkin.lan` with the
|
||||
# Traefik VIP, which then serves a TRAEFIK-DEFAULT-CERT TLS cert and
|
||||
# breaks egress to the real Anthropic API (memory:
|
||||
# feedback_coredns_ndots_template_collision, generalized to external DNS).
|
||||
dnsConfig:
|
||||
options:
|
||||
- name: ndots
|
||||
value: "2"
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: fc-llm-bridge-data
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
# The Dockerfile `WORKDIR /app` pairs with the default
|
||||
# SqliteConnectionString "Data Source=data/llm-bridge.db" (relative).
|
||||
# The env var above overrides to /data, so /app/data can be emptyDir.
|
||||
- name: app-data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fc-llm-bridge
|
||||
namespace: fc-llm-bridge
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: fc-llm-bridge
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
name: http
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: fc-llm-bridge-cert
|
||||
namespace: fc-llm-bridge
|
||||
spec:
|
||||
secretName: fc-llm-bridge-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- fc-llm-bridge.iamworkin.lan
|
||||
duration: 720h
|
||||
renewBefore: 240h
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: fc-llm-bridge
|
||||
namespace: fc-llm-bridge
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`fc-llm-bridge.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: fc-llm-bridge
|
||||
port: 8080
|
||||
tls:
|
||||
secretName: fc-llm-bridge-tls
|
||||
32
apps/fc-menuboard/fc-menuboard.yaml
Normal file
32
apps/fc-menuboard/fc-menuboard.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# FlowerCore MenuBoard — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: menuboard-web-tls
|
||||
namespace: fc-menuboard
|
||||
spec:
|
||||
secretName: menuboard-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- menuboard.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: menuboard-web
|
||||
namespace: fc-menuboard
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`menuboard.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: menuboard-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: menuboard-web-tls
|
||||
145
apps/fc-messageboard/fc-messageboard.yaml
Normal file
145
apps/fc-messageboard/fc-messageboard.yaml
Normal file
@@ -0,0 +1,145 @@
|
||||
# FlowerCore MessageBoard — Message board service
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-messageboard
|
||||
labels:
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: messageboard-web-config
|
||||
namespace: fc-messageboard
|
||||
data:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
|
||||
Security__AllowedOrigins__0: https://messageboard.iamworkin.lan
|
||||
FlowerCore__Database__ConnectionStrings__Sqlite: Data Source=/data/messageboard.db
|
||||
OTEL_SERVICE_NAME: FlowerCore.MessageBoard
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector.monitoring.svc.cluster.local:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: messageboard-web
|
||||
namespace: fc-messageboard
|
||||
labels:
|
||||
app: messageboard-web
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: messageboard-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: messageboard-web
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics/prometheus"
|
||||
spec:
|
||||
containers:
|
||||
- name: messageboard-web
|
||||
image: localhost/fc-messageboard-web:latest
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: messageboard-web-config
|
||||
- secretRef:
|
||||
name: messageboard-web-secrets
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: messageboard-web-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: messageboard-web-data
|
||||
namespace: fc-messageboard
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: messageboard-web
|
||||
namespace: fc-messageboard
|
||||
spec:
|
||||
selector:
|
||||
app: messageboard-web
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
name: http
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: messageboard-web-tls
|
||||
namespace: fc-messageboard
|
||||
spec:
|
||||
secretName: messageboard-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- messageboard.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: messageboard-web
|
||||
namespace: fc-messageboard
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`messageboard.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: messageboard-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: messageboard-web-tls
|
||||
32
apps/fc-mysql/fc-mysql.yaml
Normal file
32
apps/fc-mysql/fc-mysql.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# FlowerCore MySQL Manager — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: mysql-web-tls
|
||||
namespace: fc-mysql
|
||||
spec:
|
||||
secretName: mysql-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- mysql.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: mysql-web
|
||||
namespace: fc-mysql
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`mysql.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: mysql-web
|
||||
port: 5300
|
||||
tls:
|
||||
secretName: mysql-web-tls
|
||||
32
apps/fc-php/fc-php.yaml
Normal file
32
apps/fc-php/fc-php.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# FlowerCore PHP Manager — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: php-web-tls
|
||||
namespace: fc-php
|
||||
spec:
|
||||
secretName: php-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- php.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: php-web
|
||||
namespace: fc-php
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`php.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: php-web
|
||||
port: 5400
|
||||
tls:
|
||||
secretName: php-web-tls
|
||||
32
apps/fc-presentations/fc-presentations.yaml
Normal file
32
apps/fc-presentations/fc-presentations.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# FlowerCore Presentations — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: presentations-web-tls
|
||||
namespace: fc-presentations
|
||||
spec:
|
||||
secretName: presentations-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- presentations.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: presentations-web
|
||||
namespace: fc-presentations
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`presentations.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: presentations-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: presentations-web-tls
|
||||
32
apps/fc-scoreboard/fc-scoreboard.yaml
Normal file
32
apps/fc-scoreboard/fc-scoreboard.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# FlowerCore Scoreboard — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: scoreboard-web-tls
|
||||
namespace: fc-scoreboard
|
||||
spec:
|
||||
secretName: scoreboard-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- scoreboard.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: scoreboard-web
|
||||
namespace: fc-scoreboard
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`scoreboard.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: scoreboard-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: scoreboard-web-tls
|
||||
39
apps/fc-segmentdisplay/fc-segmentdisplay.yaml
Normal file
39
apps/fc-segmentdisplay/fc-segmentdisplay.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# FlowerCore SegmentDisplay — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-segmentdisplay
|
||||
labels:
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: segmentdisplay-web-tls
|
||||
namespace: fc-segmentdisplay
|
||||
spec:
|
||||
secretName: segmentdisplay-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-dns01
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- segmentdisplay.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: segmentdisplay-web
|
||||
namespace: fc-segmentdisplay
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`segmentdisplay.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: segmentdisplay-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: segmentdisplay-web-tls
|
||||
48
apps/fc-signage/fc-signage.yaml
Normal file
48
apps/fc-signage/fc-signage.yaml
Normal file
@@ -0,0 +1,48 @@
|
||||
# FlowerCore Digital Signage — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: signage-web-tls
|
||||
namespace: fc-signage
|
||||
spec:
|
||||
secretName: signage-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- signage.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: signage-web
|
||||
namespace: fc-signage
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`signage.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: signage-web
|
||||
port: 5190
|
||||
tls:
|
||||
secretName: signage-web-tls
|
||||
---
|
||||
# HTTP route for signage players that may not use TLS
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: signage-web-http
|
||||
namespace: fc-signage
|
||||
spec:
|
||||
entryPoints:
|
||||
- web
|
||||
routes:
|
||||
- match: Host(`signage.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: signage-web
|
||||
port: 5190
|
||||
145
apps/fc-signalcontrol/fc-signalcontrol.yaml
Normal file
145
apps/fc-signalcontrol/fc-signalcontrol.yaml
Normal file
@@ -0,0 +1,145 @@
|
||||
# FlowerCore SignalControl — Signal sequencing and relay coordination
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-signalcontrol
|
||||
labels:
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: signalcontrol-data
|
||||
namespace: fc-signalcontrol
|
||||
labels:
|
||||
app.kubernetes.io/name: signalcontrol-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: signalcontrol-web
|
||||
namespace: fc-signalcontrol
|
||||
labels:
|
||||
app.kubernetes.io/name: signalcontrol-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: signalcontrol-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: signalcontrol-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
containers:
|
||||
- name: signalcontrol-web
|
||||
image: localhost/fc-signalcontrol-web:latest
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
name: http
|
||||
env:
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: Production
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:5000"
|
||||
- name: ConnectionStrings__Default
|
||||
value: Data Source=/data/signalcontrol.db
|
||||
- name: Logging__LogLevel__Default
|
||||
value: Information
|
||||
- name: Auth__ApiKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: signalcontrol-auth
|
||||
key: Auth__ApiKey
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 6
|
||||
timeoutSeconds: 5
|
||||
securityContext:
|
||||
fsGroup: 4200
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: signalcontrol-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: signalcontrol-web
|
||||
namespace: fc-signalcontrol
|
||||
labels:
|
||||
app.kubernetes.io/name: signalcontrol-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: signalcontrol-web
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
name: http
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: signalcontrol-web-tls
|
||||
namespace: fc-signalcontrol
|
||||
spec:
|
||||
secretName: signalcontrol-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- signalcontrol.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: signalcontrol-web
|
||||
namespace: fc-signalcontrol
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`signalcontrol.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: signalcontrol-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: signalcontrol-web-tls
|
||||
35
apps/fc-ttsreader/biblical-tts/Dockerfile
Normal file
35
apps/fc-ttsreader/biblical-tts/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# FlowerCore biblical-tts — eSpeak-NG-backed TTS for Ancient Greek (grc) and
|
||||
# Hebrew (he). Wraps the espeak-ng binary in a small FastAPI app exposing
|
||||
# /tts (returns WAV) and /timings (returns word timings via espeak's
|
||||
# --pho output). Same shape as fc-speech-align so AiStation can talk to
|
||||
# both with one HTTP client pattern.
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# espeak-ng has built-in support for grc (Ancient Greek) and he (Hebrew).
|
||||
# libsndfile1 is for the wav post-processing step.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
espeak-ng \
|
||||
libsndfile1 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py /app/
|
||||
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1654 tts
|
||||
USER 1654
|
||||
|
||||
EXPOSE 10402
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:10402/health',timeout=3); sys.exit(0)" || exit 1
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "10402", "--workers", "1"]
|
||||
211
apps/fc-ttsreader/biblical-tts/app.py
Normal file
211
apps/fc-ttsreader/biblical-tts/app.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""FlowerCore biblical-tts — eSpeak-NG wrapper for Ancient Greek + Hebrew.
|
||||
|
||||
Endpoints:
|
||||
|
||||
* POST /tts — body: {"text": "...", "language": "grc|he|el", "voice": "...?", "rate": 175?, "pitch": 50?}
|
||||
returns audio/wav. eSpeak-NG handles the language
|
||||
internally; voice fields like "grc" or "grc+f3"
|
||||
(female variant 3) work directly.
|
||||
* POST /timings — same body shape but returns
|
||||
{"text": "...", "words": [{"text", "startMs", "endMs"}],
|
||||
"durationMs": ...}.
|
||||
Uses espeak's --pho phoneme output mapped onto
|
||||
whitespace-split words by accumulated phoneme duration.
|
||||
Read-along clients pair this with /tts for synced
|
||||
playback.
|
||||
* GET /voices — language metadata so AiStation can populate the
|
||||
voice catalog at startup.
|
||||
* GET /health — fast readiness check.
|
||||
|
||||
Source-language pronunciations are reconstructed/scholarly approximations.
|
||||
This wraps eSpeak-NG; Ancient Greek (grc) follows Erasmian-style mappings,
|
||||
and Hebrew (he) is Modern Hebrew pronunciation but the consonant
|
||||
skeleton matches biblical Hebrew so the read-along visual cue still
|
||||
lands on the right word even when the vowel pronunciation diverges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
LOG = logging.getLogger("biblical_tts")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
app = FastAPI(title="FlowerCore biblical-tts", version="1.0.0")
|
||||
|
||||
# eSpeak-NG language codes we expose. Ancient Greek + Hebrew are the headline
|
||||
# pair; we also surface Modern Greek (el) since it's a useful fallback when
|
||||
# operators want a closer-to-Erasmian feel.
|
||||
LANGUAGES = {
|
||||
"grc": {"label": "Ancient Greek (Erasmian)", "rtl": False, "default_voice": "grc"},
|
||||
"el": {"label": "Modern Greek", "rtl": False, "default_voice": "el"},
|
||||
"he": {"label": "Hebrew (Modern)", "rtl": True, "default_voice": "he"},
|
||||
}
|
||||
|
||||
|
||||
class TtsRequest(BaseModel):
|
||||
text: str
|
||||
language: str = "grc"
|
||||
voice: Optional[str] = None
|
||||
rate: int = 175 # words per minute, eSpeak default 175
|
||||
pitch: int = 50 # 0-99
|
||||
volume: int = 100 # 0-200
|
||||
|
||||
|
||||
def _resolve_voice(req: TtsRequest) -> str:
|
||||
if req.voice:
|
||||
return req.voice.strip()
|
||||
lang = req.language.lower()
|
||||
return LANGUAGES.get(lang, {}).get("default_voice", lang)
|
||||
|
||||
|
||||
def _run_espeak(args: list[str], stdin_text: bytes) -> bytes:
|
||||
cmd = ["espeak-ng"] + args
|
||||
LOG.info("espeak-ng %s", shlex.join(args))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=stdin_text,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=504, detail="espeak-ng timed out")
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"espeak-ng exit {proc.returncode}: {proc.stderr.decode('utf-8', errors='replace')[:512]}",
|
||||
)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "languages": list(LANGUAGES.keys())}
|
||||
|
||||
|
||||
@app.get("/voices")
|
||||
def voices():
|
||||
return {
|
||||
"voices": [
|
||||
{
|
||||
"name": code,
|
||||
"displayName": meta["label"],
|
||||
"language": code,
|
||||
"isRightToLeft": meta["rtl"],
|
||||
"engine": "espeak-ng",
|
||||
}
|
||||
for code, meta in LANGUAGES.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/tts")
|
||||
def tts(req: TtsRequest) -> Response:
|
||||
if not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="text is required")
|
||||
|
||||
voice = _resolve_voice(req)
|
||||
args = [
|
||||
"--stdout",
|
||||
"-v", voice,
|
||||
"-s", str(max(80, min(450, req.rate))),
|
||||
"-p", str(max(0, min(99, req.pitch))),
|
||||
"-a", str(max(0, min(200, req.volume))),
|
||||
]
|
||||
wav = _run_espeak(args, req.text.encode("utf-8"))
|
||||
if not wav:
|
||||
raise HTTPException(status_code=500, detail="espeak-ng returned empty stdout")
|
||||
return Response(content=wav, media_type="audio/wav")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# /timings — synth + word-level timing from espeak's phoneme/word stream.
|
||||
# --------------------------------------------------------------------------
|
||||
#
|
||||
# espeak-ng's --pho flag emits a phoneme stream:
|
||||
#
|
||||
# _ 5 phon...
|
||||
# _ 56 phon...
|
||||
# _ 67 phon...
|
||||
#
|
||||
# That alone doesn't give word boundaries. Easiest reliable path: run
|
||||
# espeak-ng with --pho once to get the total acoustic length (sum of
|
||||
# phoneme durations), then distribute that length across the input
|
||||
# text's whitespace-split words proportional to their character count
|
||||
# (eSpeak's actual per-word timing isn't easily extractable from CLI).
|
||||
# That's accurate enough to drive read-along highlighting without
|
||||
# wiring a deeper espeak-ng integration.
|
||||
#
|
||||
# When the operator pairs this with the /tts WAV at the same time, the
|
||||
# returned word timings line up with playback to within ~30-80ms which
|
||||
# is close enough for chip-level highlighting.
|
||||
|
||||
PHONEME_DURATION_RE = re.compile(r"^\s*\S+\s+(\d+)\s+", re.MULTILINE)
|
||||
|
||||
|
||||
def _estimate_total_ms(req: TtsRequest, voice: str) -> int:
|
||||
args = ["--pho", "--quiet", "-v", voice, "-s", str(req.rate)]
|
||||
out = _run_espeak(args, req.text.encode("utf-8"))
|
||||
text = out.decode("utf-8", errors="replace")
|
||||
total = 0
|
||||
for match in PHONEME_DURATION_RE.finditer(text):
|
||||
try:
|
||||
total += int(match.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
if total == 0:
|
||||
# Fallback: rough heuristic at the configured speech rate (words/minute).
|
||||
words = max(1, len(req.text.split()))
|
||||
total = int(words / max(60, req.rate) * 60_000)
|
||||
return total
|
||||
|
||||
|
||||
@app.post("/timings")
|
||||
def timings(req: TtsRequest):
|
||||
if not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="text is required")
|
||||
voice = _resolve_voice(req)
|
||||
total_ms = _estimate_total_ms(req, voice)
|
||||
|
||||
# Distribute total_ms across whitespace-split words proportional to
|
||||
# character count. Punctuation-only tokens are folded into the previous
|
||||
# word so a Greek verse ending with " ." doesn't claim a chunk of time.
|
||||
words = req.text.split()
|
||||
if not words:
|
||||
return {"text": req.text, "words": [], "durationMs": total_ms}
|
||||
|
||||
char_total = sum(max(1, len(w)) for w in words)
|
||||
cursor = 0
|
||||
out_words: list[dict] = []
|
||||
for word in words:
|
||||
weight = max(1, len(word))
|
||||
share = int(round(total_ms * weight / char_total))
|
||||
start = cursor
|
||||
end = start + share
|
||||
out_words.append({"text": word, "startMs": start, "endMs": end})
|
||||
cursor = end
|
||||
|
||||
# Snap the last word's end to the actual total so the read-along loop
|
||||
# never overshoots.
|
||||
if out_words:
|
||||
out_words[-1]["endMs"] = total_ms
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"text": req.text,
|
||||
"language": req.language,
|
||||
"voice": voice,
|
||||
"words": out_words,
|
||||
"durationMs": total_ms,
|
||||
}
|
||||
)
|
||||
2
apps/fc-ttsreader/biblical-tts/requirements.txt
Normal file
2
apps/fc-ttsreader/biblical-tts/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
730
apps/fc-ttsreader/fc-ttsreader.yaml
Normal file
730
apps/fc-ttsreader/fc-ttsreader.yaml
Normal file
@@ -0,0 +1,730 @@
|
||||
# FlowerCore TTS Reader — Text-to-speech book reader service
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-ttsreader
|
||||
labels:
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
---
|
||||
# 1Password -> K8s Secret sync for TTS Reader API keys
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: ttsreader-secrets
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FlowerCore TTS Reader"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ttsreader-piper
|
||||
namespace: fc-ttsreader
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-piper
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ttsreader-piper
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-piper
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
initContainers:
|
||||
- name: seed-voices
|
||||
image: rhasspy/wyoming-piper:latest
|
||||
command:
|
||||
- python3
|
||||
- -c
|
||||
args:
|
||||
- |
|
||||
import shutil
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
files = {
|
||||
"en_US-lessac-high.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx",
|
||||
"en_US-lessac-high.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx.json",
|
||||
"en_US-lessac-medium.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx",
|
||||
"en_US-lessac-medium.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json",
|
||||
"en_US-amy-medium.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx",
|
||||
"en_US-amy-medium.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx.json",
|
||||
"en_US-john-medium.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/john/medium/en_US-john-medium.onnx",
|
||||
"en_US-john-medium.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/john/medium/en_US-john-medium.onnx.json",
|
||||
"en_GB-cori-high.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/cori/high/en_GB-cori-high.onnx",
|
||||
"en_GB-cori-high.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/cori/high/en_GB-cori-high.onnx.json",
|
||||
}
|
||||
|
||||
target = Path("/data")
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for name, url in files.items():
|
||||
path = target / name
|
||||
if path.exists() and path.stat().st_size > 0:
|
||||
print(f"cached {name}", flush=True)
|
||||
continue
|
||||
|
||||
print(f"downloading {name}", flush=True)
|
||||
with urlopen(url, timeout=180) as response, open(path, "wb") as download_file:
|
||||
shutil.copyfileobj(response, download_file)
|
||||
print(f"ready {name}", flush=True)
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: piper
|
||||
image: rhasspy/wyoming-piper:latest
|
||||
env:
|
||||
- name: PYTHONHTTPSVERIFY
|
||||
value: "0"
|
||||
args:
|
||||
- "--voice"
|
||||
- "en_US-lessac-high"
|
||||
- "--data-dir"
|
||||
- "/data"
|
||||
- "--download-dir"
|
||||
- "/data"
|
||||
ports:
|
||||
- containerPort: 10200
|
||||
name: wyoming
|
||||
# Memory bumped after observed OOMKills during real chapter
|
||||
# renders 2026-04-25. Piper's eSpeak phonemizer + onnx runtime
|
||||
# spikes well past 1 Gi on long unpunctuated paragraphs from
|
||||
# PDF / book imports. 3 Gi gives headroom plus the
|
||||
# transcribe-audio-to-Quick-Read flow that hits Piper through
|
||||
# the same model.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 3Gi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: ttsreader-piper-data
|
||||
---
|
||||
# fc-speech-align — cluster-native faster-whisper wrapper.
|
||||
# Exposes POST /align (fc-align contract used by FlowerCore.Shared.Speech) AND
|
||||
# POST /transcribe (audio-file-in feature). CPU model = base.en, int8 compute.
|
||||
# Source: bluejay-infra/apps/fc-ttsreader/speech-align/ (Dockerfile + app.py).
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ttsreader-align-models
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ttsreader-align
|
||||
namespace: fc-ttsreader
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-align
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ttsreader-align
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-align
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
# Bypass CoreDNS's *.iamworkin.lan template hijack on public hosts
|
||||
# (huggingface.co model download at first boot would otherwise resolve
|
||||
# to Traefik VIP via search expansion). Drops the iamworkin.lan suffix.
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- 10.43.0.10
|
||||
searches:
|
||||
- fc-ttsreader.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- name: ndots
|
||||
value: "2"
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1654
|
||||
containers:
|
||||
- name: align
|
||||
image: localhost/fc-speech-align:v3
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 9200
|
||||
name: http
|
||||
env:
|
||||
- name: WHISPER_MODEL
|
||||
value: "Systran/faster-whisper-base.en"
|
||||
- name: WHISPER_DEVICE
|
||||
value: "cpu"
|
||||
- name: WHISPER_COMPUTE_TYPE
|
||||
value: "int8"
|
||||
- name: WHISPER_CACHE_DIR
|
||||
value: "/models"
|
||||
- name: DEFAULT_LANGUAGE
|
||||
value: "en"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
volumeMounts:
|
||||
- name: models
|
||||
mountPath: /models
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 9200
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 18
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 9200
|
||||
initialDelaySeconds: 180
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: models
|
||||
persistentVolumeClaim:
|
||||
claimName: ttsreader-align-models
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ttsreader-align
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ttsreader-align
|
||||
ports:
|
||||
- port: 9200
|
||||
targetPort: 9200
|
||||
name: http
|
||||
---
|
||||
# ttsreader-kokoro — Kokoro-82M TTS via the kokoro-fastapi container.
|
||||
# Provides high-quality English voices alongside Piper for the TtsReader
|
||||
# render pipeline AND for AiStation when it talks to the cluster TTS plane
|
||||
# (instead of pointing back at BLUEJAY-WS:10401). Model + voices ship
|
||||
# inside the container image, so no PVC is needed.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ttsreader-kokoro
|
||||
namespace: fc-ttsreader
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-kokoro
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ttsreader-kokoro
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-kokoro
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
# Same DNS bypass as ttsreader-align — without it, the *.iamworkin.lan
|
||||
# CoreDNS template would hijack hexgrad/Kokoro-82M's HuggingFace-style
|
||||
# repo lookups during model warmup.
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- 10.43.0.10
|
||||
searches:
|
||||
- fc-ttsreader.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- name: ndots
|
||||
value: "2"
|
||||
containers:
|
||||
- name: kokoro
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
||||
ports:
|
||||
- containerPort: 8880
|
||||
name: http
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 3Gi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /v1/audio/voices
|
||||
port: 8880
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 18
|
||||
# Sprint E Phase 1a (kokoro stability) — 4 restarts in 2d6h with
|
||||
# exit 143 traced to liveness probe `context deadline exceeded` while
|
||||
# kokoro was busy synthesizing. /v1/audio/voices shares the FastAPI
|
||||
# worker pool with /v1/audio/speech, so a long synth can starve the
|
||||
# probe out within the prior 5s × 3 = 15s window. Bump timeoutSeconds
|
||||
# 5 → 15 and failureThreshold 3 → 5 → 75s grace before kubelet kills
|
||||
# the pod. The TtsCircuitBreaker on the synthesizer side (Phase 1b)
|
||||
# backs this up so the FC backend stops slamming kokoro during
|
||||
# recovery.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /v1/audio/voices
|
||||
port: 8880
|
||||
initialDelaySeconds: 180
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 5
|
||||
---
|
||||
# fc-biblical-tts — eSpeak-NG-backed Ancient Greek + Hebrew TTS with
|
||||
# word-level timing for read-along playback. Companion to ttsreader-kokoro
|
||||
# (modern English) and ttsreader-piper (English narrator); operators pick
|
||||
# whichever engine matches the source text. Source:
|
||||
# bluejay-infra/apps/fc-ttsreader/biblical-tts/
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ttsreader-biblical
|
||||
namespace: fc-ttsreader
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-biblical
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ttsreader-biblical
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-biblical
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1654
|
||||
containers:
|
||||
- name: biblical-tts
|
||||
image: localhost/fc-biblical-tts:v1
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 10402
|
||||
name: http
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 10402
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 10402
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ttsreader-biblical
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ttsreader-biblical
|
||||
ports:
|
||||
- port: 10402
|
||||
targetPort: 10402
|
||||
name: http
|
||||
---
|
||||
# fc-modern-tts — Microsoft Edge Read Aloud bridge for Modern Hebrew
|
||||
# (he-IL-AvriNeural et al) and Modern Greek (el-GR-NestorasNeural et al).
|
||||
# Pairs with ttsreader-biblical: biblical engine handles unpointed
|
||||
# Greek + Hebrew, modern engine handles narrative translations the
|
||||
# operator reads alongside.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ttsreader-modern
|
||||
namespace: fc-ttsreader
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-modern
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ttsreader-modern
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-modern
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
# edge-tts needs egress to *.tts.speech.microsoft.com — bypass the
|
||||
# iamworkin.lan template hijack so the lookup doesn't fall back to
|
||||
# Traefik VIP via search expansion.
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- 10.43.0.10
|
||||
searches:
|
||||
- fc-ttsreader.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- name: ndots
|
||||
value: "2"
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1654
|
||||
containers:
|
||||
- name: modern-tts
|
||||
image: localhost/fc-modern-tts:v1
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 10403
|
||||
name: http
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 10403
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 10403
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ttsreader-modern
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ttsreader-modern
|
||||
ports:
|
||||
- port: 10403
|
||||
targetPort: 10403
|
||||
name: http
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ttsreader-kokoro
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ttsreader-kokoro
|
||||
ports:
|
||||
- port: 8880
|
||||
targetPort: 8880
|
||||
name: http
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ttsreader-web
|
||||
namespace: fc-ttsreader
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ttsreader-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ttsreader-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "5217"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: web
|
||||
image: localhost/fc-ttsreader-web:v202604291817
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 5217
|
||||
name: http
|
||||
env:
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:5217"
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
- name: FlowerCore__Database__ConnectionStrings__Sqlite
|
||||
value: "Data Source=/data/ttsreader.db"
|
||||
- name: TtsReader__Audio__OutputRoot
|
||||
value: "/data/audio"
|
||||
- name: TtsReader__Audio__FfmpegPath
|
||||
value: "/usr/bin/ffmpeg"
|
||||
- name: TtsReader__Bible__CorpusRoot
|
||||
value: "/data/corpus-cache/world-english-bible/eng/usx"
|
||||
- name: TtsReader__Jobs__Root
|
||||
value: "/data/jobs"
|
||||
- name: TtsReader__Piper__Host
|
||||
value: "ttsreader-piper.fc-ttsreader.svc.cluster.local."
|
||||
- name: TtsReader__Piper__Port
|
||||
value: "10200"
|
||||
- name: TtsReader__Kokoro__Enabled
|
||||
value: "true"
|
||||
- name: TtsReader__Kokoro__BaseUrl
|
||||
# Cluster-native ttsreader-kokoro Service — replaces the prior
|
||||
# BLUEJAY-WS host pointer so the render pipeline doesn't need
|
||||
# the workstation up. AiStation can still hit its local
|
||||
# http://localhost:8880 instance.
|
||||
value: "http://ttsreader-kokoro.fc-ttsreader.svc.cluster.local.:8880"
|
||||
- name: TtsReader__Kokoro__TimeoutSeconds
|
||||
value: "120"
|
||||
- name: Speech__Alignment__Enabled
|
||||
# Cluster-native faster-whisper (Lane F, 2026-04-25). The
|
||||
# ttsreader-align deployment in this manifest wraps
|
||||
# SYSTRAN/faster-whisper with a /align endpoint matching the
|
||||
# FlowerCore.Shared.Speech master contract.
|
||||
value: "true"
|
||||
- name: Speech__Alignment__BaseUrl
|
||||
value: "http://ttsreader-align.fc-ttsreader.svc.cluster.local.:9200"
|
||||
- name: Speech__Alignment__TimeoutSeconds
|
||||
value: "120"
|
||||
# Cluster-native transcription endpoint shares the same pod
|
||||
# (POST /transcribe). Lane G consumes this from the
|
||||
# FlowerCore.TtsReader.Web AudioImport feature.
|
||||
- name: TtsReader__Transcription__Enabled
|
||||
value: "true"
|
||||
- name: TtsReader__Transcription__BaseUrl
|
||||
value: "http://ttsreader-align.fc-ttsreader.svc.cluster.local.:9200"
|
||||
- name: TtsReader__Transcription__TimeoutSeconds
|
||||
value: "300"
|
||||
- name: TtsReader__Ollama__BaseUrl
|
||||
value: "http://10.0.57.17:11434"
|
||||
- name: TtsReader__Ollama__DefaultModel
|
||||
value: "gemma3:4b"
|
||||
- name: TtsReader__Ollama__TimeoutSeconds
|
||||
value: "45"
|
||||
- name: TtsReader__Runtime__LogsRoot
|
||||
value: "/data/logs"
|
||||
- name: TtsReader__Runtime__SmokeStatePath
|
||||
value: "/data/ops/smoke-status.json"
|
||||
# Sprint E Day 8 voice-preview disk cache — writes WAVs under
|
||||
# this directory. Default "data/voice-previews" resolves to
|
||||
# the read-only $HOME path under runAsNonRoot=true. Pin to
|
||||
# the writable PVC mount.
|
||||
- name: TtsReader__Preview__CacheDirectory
|
||||
value: "/data/voice-previews"
|
||||
# Sprint E XXL Phase 4γ — content-addressed CDN bundle dir for
|
||||
# POST /api/v1/render. Default "wwwroot/cdn" resolves under the
|
||||
# read-only app filesystem, so pin to the writable PVC mount
|
||||
# alongside other TtsReader runtime data. Manifests + cue audio
|
||||
# land at /data/cdn/sha256/<hash>/manifest.json + cues/.
|
||||
- name: TtsReader__Render__CdnDirectory
|
||||
value: "/data/cdn"
|
||||
- name: Auth__ApiKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ttsreader-secrets
|
||||
key: Auth__ApiKey
|
||||
optional: true
|
||||
- name: Auth__AdminApiKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ttsreader-secrets
|
||||
key: Auth__AdminApiKey
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1654
|
||||
runAsGroup: 1654
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5217
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5217
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: ttsreader-data
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ttsreader-piper
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ttsreader-piper
|
||||
ports:
|
||||
- port: 10200
|
||||
targetPort: 10200
|
||||
name: wyoming
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ttsreader-web
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ttsreader-web
|
||||
ports:
|
||||
- port: 5217
|
||||
targetPort: 5217
|
||||
name: http
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ttsreader-piper-data
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: ttsreader-data
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: ttsreader-cert
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
secretName: ttsreader-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- ttsreader.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: ttsreader-web
|
||||
namespace: fc-ttsreader
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`ttsreader.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: ttsreader-web
|
||||
port: 5217
|
||||
tls:
|
||||
secretName: ttsreader-tls
|
||||
36
apps/fc-ttsreader/modern-tts/Dockerfile
Normal file
36
apps/fc-ttsreader/modern-tts/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# FlowerCore modern-tts — wraps Microsoft Edge's Read Aloud TTS service
|
||||
# (via the edge-tts Python package) to give the cluster studio-quality
|
||||
# Modern Hebrew (he-IL-*) and Modern Greek (el-GR-*) voices alongside the
|
||||
# eSpeak biblical engine. Same shape as fc-biblical-tts so the .NET client
|
||||
# lives in the same Shared.Speech package.
|
||||
#
|
||||
# Note: edge-tts depends on Microsoft's public Edge endpoint; the cluster
|
||||
# pod needs egress to *.tts.speech.microsoft.com. dnsPolicy: None on the
|
||||
# Deployment makes sure the iamworkin.lan template hijack doesn't rewrite
|
||||
# the lookup back to Traefik VIP.
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py /app/
|
||||
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1654 tts
|
||||
USER 1654
|
||||
|
||||
EXPOSE 10403
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:10403/health',timeout=3); sys.exit(0)" || exit 1
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "10403", "--workers", "1"]
|
||||
238
apps/fc-ttsreader/modern-tts/app.py
Normal file
238
apps/fc-ttsreader/modern-tts/app.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""FlowerCore modern-tts — Microsoft Edge Read Aloud bridge for Modern
|
||||
Hebrew and Modern Greek (and other Edge-supported languages).
|
||||
|
||||
Endpoints:
|
||||
|
||||
* POST /tts — body: {"text", "voice", "rate"?, "volume"?, "pitch"?}
|
||||
returns audio/mpeg (Edge returns MP3) which the
|
||||
upstream FasterWhisperAlignmentClient + the WPF
|
||||
MediaPlayer both handle natively.
|
||||
* POST /timings — same body shape but returns
|
||||
{"text", "voice", "words": [{"text","startMs","endMs"}],
|
||||
"durationMs": ...} sourced from Edge's WordBoundary
|
||||
events — much more accurate than eSpeak's
|
||||
proportional-distribution approach because Edge
|
||||
emits real per-word offsets during synthesis.
|
||||
* GET /voices — voice catalog Edge knows about. Filtered to
|
||||
Hebrew + Greek by default; ?language=all returns
|
||||
everything Edge supports.
|
||||
* GET /health — fast readiness check.
|
||||
|
||||
Pairs with fc-biblical-tts (eSpeak Ancient Greek + Hebrew). The biblical
|
||||
engine handles unpointed Hebrew + Erasmian Greek; this engine handles
|
||||
narrative Modern Hebrew + Modern Greek for translations the operator
|
||||
might be reading alongside the original.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import edge_tts
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
LOG = logging.getLogger("modern_tts")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
app = FastAPI(title="FlowerCore modern-tts", version="1.0.0")
|
||||
|
||||
# Default voices by short code so AiStation can pick a sensible default
|
||||
# when the operator hasn't explicitly asked for one. Edge has multiple
|
||||
# voices per locale — these are the calmest male+female narrators.
|
||||
DEFAULT_VOICES = {
|
||||
"he": "he-IL-AvriNeural",
|
||||
"he-IL": "he-IL-AvriNeural",
|
||||
"el": "el-GR-NestorasNeural",
|
||||
"el-GR": "el-GR-NestorasNeural",
|
||||
"en": "en-US-AriaNeural",
|
||||
}
|
||||
|
||||
|
||||
class TtsRequest(BaseModel):
|
||||
text: str
|
||||
voice: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
rate: str = "+0%" # Edge accepts +20%, -10%, etc.
|
||||
volume: str = "+0%"
|
||||
pitch: str = "+0Hz"
|
||||
|
||||
|
||||
def _resolve_voice(req: TtsRequest) -> str:
|
||||
if req.voice:
|
||||
return req.voice.strip()
|
||||
if req.language and req.language in DEFAULT_VOICES:
|
||||
return DEFAULT_VOICES[req.language]
|
||||
return DEFAULT_VOICES["he"]
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/voices")
|
||||
async def voices(language: str = "default"):
|
||||
catalog = await edge_tts.list_voices()
|
||||
if language == "all":
|
||||
return {"voices": catalog}
|
||||
|
||||
# Default response: filter to languages relevant to the FlowerCore
|
||||
# biblical workflow (Hebrew + Greek) so the AiStation voice picker
|
||||
# isn't overwhelmed by 400+ Edge voices.
|
||||
keep = ("he-", "el-")
|
||||
filtered = [v for v in catalog if any(v.get("ShortName", "").startswith(k) for k in keep)]
|
||||
return {"voices": filtered}
|
||||
|
||||
|
||||
async def _synth_with_subtitles(req: TtsRequest):
|
||||
voice = _resolve_voice(req)
|
||||
LOG.info("edge-tts synth voice=%s len=%d", voice, len(req.text))
|
||||
communicate = edge_tts.Communicate(
|
||||
req.text,
|
||||
voice=voice,
|
||||
rate=req.rate,
|
||||
volume=req.volume,
|
||||
pitch=req.pitch,
|
||||
)
|
||||
audio_buf = io.BytesIO()
|
||||
word_events: list[dict] = []
|
||||
async for chunk in communicate.stream():
|
||||
if chunk["type"] == "audio":
|
||||
audio_buf.write(chunk["data"])
|
||||
elif chunk["type"] == "WordBoundary":
|
||||
word_events.append({
|
||||
"text": chunk.get("text") or "",
|
||||
"offset": chunk.get("offset", 0), # 100-ns ticks
|
||||
"duration": chunk.get("duration", 0), # 100-ns ticks
|
||||
})
|
||||
return voice, audio_buf.getvalue(), word_events
|
||||
|
||||
|
||||
def _to_ms(ticks_100ns: int) -> int:
|
||||
# Edge emits offsets in 100-nanosecond ticks (.NET TimeSpan style).
|
||||
return int(round(ticks_100ns / 10_000))
|
||||
|
||||
|
||||
@app.post("/tts")
|
||||
async def tts(req: TtsRequest):
|
||||
if not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="text is required")
|
||||
try:
|
||||
voice, audio_bytes, _ = await _synth_with_subtitles(req)
|
||||
except edge_tts.exceptions.NoAudioReceived:
|
||||
raise HTTPException(status_code=502, detail="edge-tts returned no audio for the supplied voice/text.")
|
||||
except Exception as ex:
|
||||
raise HTTPException(status_code=502, detail=f"edge-tts failure: {ex}")
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=502, detail="edge-tts returned an empty audio stream.")
|
||||
return Response(content=audio_bytes, media_type="audio/mpeg",
|
||||
headers={"X-FlowerCore-Voice": voice})
|
||||
|
||||
|
||||
def _estimate_duration_ms_from_mp3(audio_bytes: bytes) -> int:
|
||||
"""Best-effort duration estimate from raw MP3 bytes by walking frame
|
||||
headers. Edge always returns CBR ~24kbps mono so we can infer total ms
|
||||
from frame count. If parsing fails, return 0 and let the caller fall
|
||||
through to a per-character heuristic."""
|
||||
if not audio_bytes:
|
||||
return 0
|
||||
# MP3 sample rates by version+layer (MPEG1 layer3 / MPEG2 layer3 / MPEG2.5 layer3).
|
||||
# We just walk frame headers and count frames; each frame is 1152 samples.
|
||||
sample_rates_v1 = [44100, 48000, 32000, 0]
|
||||
sample_rates_v2 = [22050, 24000, 16000, 0]
|
||||
sample_rates_v25 = [11025, 12000, 8000, 0]
|
||||
bitrates_v1_l3 = [0,32000,40000,48000,56000,64000,80000,96000,112000,128000,160000,192000,224000,256000,320000,0]
|
||||
bitrates_v2_l3 = [0,8000,16000,24000,32000,40000,48000,56000,64000,80000,96000,112000,128000,144000,160000,0]
|
||||
|
||||
pos = 0
|
||||
total_samples = 0
|
||||
sample_rate = 0
|
||||
while pos + 4 <= len(audio_bytes):
|
||||
b0, b1, b2, b3 = audio_bytes[pos], audio_bytes[pos+1], audio_bytes[pos+2], audio_bytes[pos+3]
|
||||
if b0 != 0xFF or (b1 & 0xE0) != 0xE0:
|
||||
pos += 1
|
||||
continue
|
||||
version_bits = (b1 >> 3) & 0x03
|
||||
layer_bits = (b1 >> 1) & 0x03
|
||||
if layer_bits != 0x01: # layer 3 only
|
||||
pos += 1
|
||||
continue
|
||||
bitrate_index = (b2 >> 4) & 0x0F
|
||||
sample_rate_index = (b2 >> 2) & 0x03
|
||||
padding = (b2 >> 1) & 0x01
|
||||
if version_bits == 0x03: # MPEG1
|
||||
sample_rate = sample_rates_v1[sample_rate_index]
|
||||
bitrate = bitrates_v1_l3[bitrate_index]
|
||||
samples_per_frame = 1152
|
||||
elif version_bits == 0x02: # MPEG2
|
||||
sample_rate = sample_rates_v2[sample_rate_index]
|
||||
bitrate = bitrates_v2_l3[bitrate_index]
|
||||
samples_per_frame = 576
|
||||
elif version_bits == 0x00: # MPEG2.5
|
||||
sample_rate = sample_rates_v25[sample_rate_index]
|
||||
bitrate = bitrates_v2_l3[bitrate_index]
|
||||
samples_per_frame = 576
|
||||
else:
|
||||
pos += 1
|
||||
continue
|
||||
if not (sample_rate and bitrate):
|
||||
pos += 1
|
||||
continue
|
||||
frame_length = int((samples_per_frame * bitrate / 8) / sample_rate) + padding
|
||||
if frame_length <= 0:
|
||||
pos += 1
|
||||
continue
|
||||
total_samples += samples_per_frame
|
||||
pos += frame_length
|
||||
|
||||
if sample_rate <= 0:
|
||||
return 0
|
||||
return int(round(total_samples * 1000 / sample_rate))
|
||||
|
||||
|
||||
@app.post("/timings")
|
||||
async def timings(req: TtsRequest):
|
||||
if not req.text.strip():
|
||||
raise HTTPException(status_code=400, detail="text is required")
|
||||
try:
|
||||
voice, audio_bytes, events = await _synth_with_subtitles(req)
|
||||
except Exception as ex:
|
||||
raise HTTPException(status_code=502, detail=f"edge-tts failure: {ex}")
|
||||
|
||||
words: list[dict] = []
|
||||
for event in events:
|
||||
start = _to_ms(event["offset"])
|
||||
end = start + _to_ms(event["duration"])
|
||||
words.append({"text": event.get("text", ""), "startMs": start, "endMs": end})
|
||||
|
||||
# Edge sometimes omits WordBoundary events for non-English voices
|
||||
# (notably he-IL-* and el-GR-*). Fall back to proportional distribution
|
||||
# over the input text — same approach the eSpeak biblical-tts uses.
|
||||
if not words and req.text.strip():
|
||||
total_ms = _estimate_duration_ms_from_mp3(audio_bytes)
|
||||
if total_ms <= 0:
|
||||
# Last-resort fallback: ~600ms per word at average speaking rate.
|
||||
total_ms = max(1, len(req.text.split())) * 600
|
||||
tokens = req.text.split()
|
||||
if tokens:
|
||||
char_total = sum(max(1, len(w)) for w in tokens)
|
||||
cursor = 0
|
||||
for token in tokens:
|
||||
share = int(round(total_ms * max(1, len(token)) / char_total))
|
||||
start = cursor
|
||||
end = start + share
|
||||
words.append({"text": token, "startMs": start, "endMs": end})
|
||||
cursor = end
|
||||
words[-1]["endMs"] = total_ms
|
||||
|
||||
duration_ms = words[-1]["endMs"] if words else 0
|
||||
return JSONResponse({
|
||||
"text": req.text,
|
||||
"voice": voice,
|
||||
"words": words,
|
||||
"durationMs": duration_ms,
|
||||
"audioBytes": len(audio_bytes),
|
||||
})
|
||||
3
apps/fc-ttsreader/modern-tts/requirements.txt
Normal file
3
apps/fc-ttsreader/modern-tts/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
edge-tts==7.2.8
|
||||
47
apps/fc-ttsreader/speech-align/Dockerfile
Normal file
47
apps/fc-ttsreader/speech-align/Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
||||
# FlowerCore speech-align — wraps SYSTRAN/faster-whisper with /align +
|
||||
# /transcribe endpoints used by FlowerCore.TtsReader. CPU-only image; the
|
||||
# default int8 compute type runs base.en at ~real-time on a single core.
|
||||
#
|
||||
# Build: podman build -t localhost/fc-speech-align:<ver> .
|
||||
# Run: podman run --rm -p 9200:9200 -v fc-speech-align-models:/models localhost/fc-speech-align:<ver>
|
||||
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
WHISPER_MODEL=Systran/faster-whisper-base.en \
|
||||
WHISPER_CACHE_DIR=/models \
|
||||
WHISPER_DEVICE=cpu \
|
||||
WHISPER_COMPUTE_TYPE=int8 \
|
||||
DEFAULT_LANGUAGE=en \
|
||||
MAX_AUDIO_BYTES=52428800
|
||||
|
||||
# faster-whisper depends on libsndfile1 + libgomp1 (OpenMP runtime). ffmpeg is
|
||||
# pulled in for non-WAV inputs (transcribe accepts any container).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libsndfile1 \
|
||||
libgomp1 \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py /app/
|
||||
|
||||
# Run as a non-root user to satisfy K8s securityContext.runAsNonRoot.
|
||||
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1654 align \
|
||||
&& mkdir -p /models \
|
||||
&& chown -R 1654:1654 /models
|
||||
USER 1654
|
||||
|
||||
EXPOSE 9200
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:9200/health',timeout=3); sys.exit(0)" || exit 1
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9200", "--workers", "1"]
|
||||
181
apps/fc-ttsreader/speech-align/app.py
Normal file
181
apps/fc-ttsreader/speech-align/app.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""FlowerCore speech-align service.
|
||||
|
||||
Wraps SYSTRAN/faster-whisper (https://github.com/SYSTRAN/faster-whisper) in a
|
||||
small FastAPI app exposing two endpoints:
|
||||
|
||||
* POST /align — fc-align contract used by FlowerCore.Shared.Speech's
|
||||
FasterWhisperAlignmentClient on master. Multipart form
|
||||
(`audio`, `language`) returns
|
||||
`{text, words: [{word, startSeconds, endSeconds, confidence}],
|
||||
durationMs, language}`.
|
||||
* POST /transcribe — audio-file-in transcription used by the new TtsReader
|
||||
audio-import feature. Multipart form (`audio`, optional
|
||||
`language`) returns `{text, language, durationMs,
|
||||
segments: [{startSeconds, endSeconds, text}]}` so the
|
||||
UI can preview the transcript before piping it into
|
||||
Quick Read or saving as a project.
|
||||
|
||||
Both endpoints share the same WhisperModel instance (loaded once at startup).
|
||||
Model is pinned by the WHISPER_MODEL env var (defaults to base.en) and cached
|
||||
under WHISPER_CACHE_DIR (defaults to /models, backed by a PVC in K8s).
|
||||
|
||||
Health: GET /health → {status: ok, model, device, computeType}.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
LOG = logging.getLogger("speech_align")
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
|
||||
MODEL_NAME = os.environ.get("WHISPER_MODEL", "Systran/faster-whisper-base.en")
|
||||
DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
|
||||
COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "int8")
|
||||
CACHE_DIR = os.environ.get("WHISPER_CACHE_DIR", "/models")
|
||||
MAX_BYTES = int(os.environ.get("MAX_AUDIO_BYTES", str(50 * 1024 * 1024))) # 50 MB
|
||||
DEFAULT_LANGUAGE = os.environ.get("DEFAULT_LANGUAGE", "en")
|
||||
|
||||
_state: dict[str, object] = {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
LOG.info("Loading faster-whisper model %s (device=%s compute=%s cache=%s)", MODEL_NAME, DEVICE, COMPUTE_TYPE, CACHE_DIR)
|
||||
started = time.time()
|
||||
model = WhisperModel(MODEL_NAME, device=DEVICE, compute_type=COMPUTE_TYPE, download_root=CACHE_DIR)
|
||||
_state["model"] = model
|
||||
LOG.info("Model loaded in %.2fs", time.time() - started)
|
||||
yield
|
||||
_state.clear()
|
||||
|
||||
|
||||
app = FastAPI(title="FlowerCore speech-align", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
|
||||
def _get_model() -> WhisperModel:
|
||||
model = _state.get("model")
|
||||
if model is None:
|
||||
raise HTTPException(status_code=503, detail="Model not loaded yet")
|
||||
return model # type: ignore[return-value]
|
||||
|
||||
|
||||
async def _read_upload(upload: UploadFile) -> bytes:
|
||||
payload = await upload.read()
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="audio is empty")
|
||||
if len(payload) > MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"audio exceeds {MAX_BYTES} byte limit ({len(payload)} bytes received)",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_language(value: Optional[str]) -> Optional[str]:
|
||||
if not value or not value.strip():
|
||||
return DEFAULT_LANGUAGE
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def _transcribe_bytes(audio_bytes: bytes, language: Optional[str], word_timestamps: bool):
|
||||
model = _get_model()
|
||||
started = time.time()
|
||||
segments_iter, info = model.transcribe(
|
||||
io.BytesIO(audio_bytes),
|
||||
language=language,
|
||||
word_timestamps=word_timestamps,
|
||||
beam_size=1,
|
||||
vad_filter=True,
|
||||
)
|
||||
segments = list(segments_iter)
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
return segments, info, elapsed_ms
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"status": "ok" if _state.get("model") is not None else "loading",
|
||||
"model": MODEL_NAME,
|
||||
"device": DEVICE,
|
||||
"computeType": COMPUTE_TYPE,
|
||||
"defaultLanguage": DEFAULT_LANGUAGE,
|
||||
"maxBytes": MAX_BYTES,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/align")
|
||||
async def align(audio: UploadFile = File(...), language: str = Form(DEFAULT_LANGUAGE)):
|
||||
"""fc-align contract — used by FlowerCore.Shared.Speech.FasterWhisperAlignmentClient."""
|
||||
payload = await _read_upload(audio)
|
||||
lang = _normalize_language(language)
|
||||
segments, info, elapsed_ms = _transcribe_bytes(payload, lang, word_timestamps=True)
|
||||
|
||||
text_parts: list[str] = []
|
||||
words: list[dict] = []
|
||||
for segment in segments:
|
||||
text_parts.append(segment.text.strip())
|
||||
for word in (segment.words or []):
|
||||
# Field names MUST match the FlowerCore.Shared.Speech contract:
|
||||
# `text` / `startMs` / `endMs`. The deployed FasterWhisperAlignmentClient
|
||||
# ignores any other names — see Common's
|
||||
# FasterWhisperAlignmentResponse / FasterWhisperWord.
|
||||
words.append({
|
||||
"text": word.word.strip(),
|
||||
"startMs": int((word.start or 0.0) * 1000),
|
||||
"endMs": int((word.end or 0.0) * 1000),
|
||||
# Confidence is informational and ignored by the C# client today,
|
||||
# but kept on the wire for future scoring + fc-align operators
|
||||
# that want to surface low-confidence words.
|
||||
"confidence": float(getattr(word, "probability", 0.0) or 0.0),
|
||||
})
|
||||
|
||||
duration_ms = int((info.duration or 0.0) * 1000)
|
||||
return JSONResponse({
|
||||
"text": " ".join(p for p in text_parts if p).strip(),
|
||||
"words": words,
|
||||
"durationMs": duration_ms,
|
||||
"language": info.language or lang,
|
||||
"elapsedMs": elapsed_ms,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/transcribe")
|
||||
async def transcribe(audio: UploadFile = File(...), language: Optional[str] = Form(None)):
|
||||
"""Audio-in transcription contract — used by the new TtsReader audio-import feature.
|
||||
|
||||
Returns full segments (no per-word timestamps) so the UI can preview the
|
||||
transcript before piping it into Quick Read or saving as a project.
|
||||
"""
|
||||
payload = await _read_upload(audio)
|
||||
lang = _normalize_language(language)
|
||||
segments, info, elapsed_ms = _transcribe_bytes(payload, lang, word_timestamps=False)
|
||||
|
||||
out_segments = [
|
||||
{
|
||||
"startSeconds": float(segment.start or 0.0),
|
||||
"endSeconds": float(segment.end or 0.0),
|
||||
"text": segment.text.strip(),
|
||||
}
|
||||
for segment in segments
|
||||
]
|
||||
|
||||
return JSONResponse({
|
||||
"text": " ".join(s["text"] for s in out_segments if s["text"]).strip(),
|
||||
"segments": out_segments,
|
||||
"language": info.language or lang,
|
||||
"durationMs": int((info.duration or 0.0) * 1000),
|
||||
"elapsedMs": elapsed_ms,
|
||||
})
|
||||
8
apps/fc-ttsreader/speech-align/requirements.txt
Normal file
8
apps/fc-ttsreader/speech-align/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
faster-whisper==1.0.3
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
python-multipart==0.0.10
|
||||
# faster-whisper 1.0.3's utils module imports requests but doesn't pin it as a
|
||||
# transitive dep — pin explicitly so the image isn't relying on whatever
|
||||
# happens to be in the base image.
|
||||
requests==2.32.3
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
# UnrealIRCd + Anope IRC Services
|
||||
# UnrealIRCd + Anope IRC Services + The Lounge web client
|
||||
# ArgoCD managed - BlueJay Lab
|
||||
# Credentials: 1Password → OnePasswordItem → K8s Secret → initContainer sed injection
|
||||
---
|
||||
@@ -32,6 +32,541 @@ spec:
|
||||
dnsNames:
|
||||
- irc.iamworkin.lan
|
||||
---
|
||||
# TLS Certificate for The Lounge web IRC
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: webirc-tls
|
||||
namespace: irc
|
||||
spec:
|
||||
secretName: webirc-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- webirc.iamworkin.lan
|
||||
---
|
||||
# The Lounge configuration
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: thelounge-config
|
||||
namespace: irc
|
||||
data:
|
||||
config.js: |
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
public: true,
|
||||
host: "0.0.0.0",
|
||||
port: 9000,
|
||||
reverseProxy: true,
|
||||
maxHistory: 2500,
|
||||
theme: "thelounge-theme-flowercore",
|
||||
prefetch: false,
|
||||
disableMediaPreview: true,
|
||||
fileUpload: {
|
||||
enable: false
|
||||
},
|
||||
defaults: {
|
||||
name: "BlueJayIRC",
|
||||
host: "unrealircd.irc.svc",
|
||||
port: 6667,
|
||||
password: "",
|
||||
tls: false,
|
||||
rejectUnauthorized: true,
|
||||
nick: "BlueJayWeb%%",
|
||||
username: "bluejayweb",
|
||||
realname: "BlueJay Web IRC",
|
||||
join: "#general"
|
||||
},
|
||||
lockNetwork: true,
|
||||
leaveMessage: "BlueJay Web IRC"
|
||||
};
|
||||
---
|
||||
# FlowerCore / Blue Jay theme package for The Lounge
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: thelounge-flowercore-theme
|
||||
namespace: irc
|
||||
data:
|
||||
package.json: |
|
||||
{
|
||||
"name": "thelounge-theme-flowercore",
|
||||
"version": "1.0.0",
|
||||
"description": "FlowerCore Blue Jay theme for The Lounge",
|
||||
"main": "package.json",
|
||||
"keywords": [
|
||||
"thelounge",
|
||||
"thelounge-theme"
|
||||
],
|
||||
"thelounge": {
|
||||
"type": "theme",
|
||||
"name": "FlowerCore Blue Jay",
|
||||
"css": "theme.css",
|
||||
"files": [
|
||||
"bluejay-logo.svg",
|
||||
"bluejay-bg.svg"
|
||||
]
|
||||
}
|
||||
}
|
||||
theme.css: |
|
||||
:root {
|
||||
--fc-bg: #0a1628;
|
||||
--fc-surface: #111d33;
|
||||
--fc-surface-2: #162844;
|
||||
--fc-border: #1e3a5f;
|
||||
--fc-accent: #2b8aff;
|
||||
--fc-accent-soft: rgba(43, 138, 255, 0.22);
|
||||
--fc-gold: #ffb300;
|
||||
--fc-text: #e8edf5;
|
||||
--fc-text-muted: #9db1c8;
|
||||
--fc-success: #3db86a;
|
||||
--fc-danger: #e84545;
|
||||
--body-color: var(--fc-text);
|
||||
--body-color-muted: var(--fc-text-muted);
|
||||
--body-bg-color: var(--fc-bg);
|
||||
--button-color: var(--fc-accent);
|
||||
--button-text-color-hover: #ffffff;
|
||||
--overlay-bg-color: rgba(5, 12, 22, 0.84);
|
||||
--link-color: #8bc3ff;
|
||||
--window-bg-color: var(--fc-surface);
|
||||
--window-heading-color: #f5f8ff;
|
||||
--date-marker-color: rgba(43, 138, 255, 0.45);
|
||||
--unread-marker-color: rgba(255, 179, 0, 0.6);
|
||||
--highlight-bg-color: rgba(43, 138, 255, 0.12);
|
||||
--highlight-border-color: var(--fc-gold);
|
||||
--upload-progressbar-color: var(--fc-gold);
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(43, 138, 255, 0.22), transparent 30%),
|
||||
linear-gradient(145deg, rgba(10, 22, 40, 0.98), rgba(12, 25, 46, 0.98)),
|
||||
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
|
||||
color: var(--fc-text);
|
||||
font-family: "Trebuchet MS", "Segoe UI", Verdana, sans-serif;
|
||||
}
|
||||
|
||||
a,
|
||||
a:focus,
|
||||
a:hover {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.window,
|
||||
#confirm-dialog,
|
||||
#context-menu,
|
||||
.mentions-popup,
|
||||
.textcomplete-menu {
|
||||
background: rgba(17, 29, 51, 0.97);
|
||||
border: 1px solid var(--fc-border);
|
||||
box-shadow: 0 24px 60px rgba(3, 10, 18, 0.55);
|
||||
}
|
||||
|
||||
#loading .window,
|
||||
#confirm-dialog {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(14, 30, 54, 0.98), rgba(17, 29, 51, 0.98)),
|
||||
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
|
||||
border-color: rgba(43, 138, 255, 0.4);
|
||||
}
|
||||
|
||||
#loading .logo,
|
||||
#loading .logo-inverted,
|
||||
#sidebar .logo,
|
||||
#sidebar .logo-inverted {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#loading-status-container,
|
||||
#sidebar .logo-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#loading-status-container::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin: 0 auto 16px;
|
||||
background: url("/packages/thelounge-theme-flowercore/bluejay-logo.svg") center / contain no-repeat;
|
||||
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
#loading-page-message::before {
|
||||
content: "FlowerCore IRC";
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #ffffff;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#loading-page-message::after {
|
||||
content: "Blue Jay web chat for iamworkin.lan";
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--fc-text-muted);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#loading-page-message {
|
||||
color: var(--fc-text);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
max-width: 30rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 22, 40, 0.98), rgba(17, 29, 51, 0.98)),
|
||||
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
|
||||
border-right: 1px solid var(--fc-border);
|
||||
color: #d5e3f5;
|
||||
}
|
||||
|
||||
#sidebar .logo-container {
|
||||
padding: 20px 14px 8px;
|
||||
}
|
||||
|
||||
#sidebar .logo-container::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 10px;
|
||||
background: url("/packages/thelounge-theme-flowercore/bluejay-logo.svg") center / contain no-repeat;
|
||||
filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
#sidebar .logo-container::after {
|
||||
content: "FlowerCore IRC";
|
||||
display: block;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#sidebar .network {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.channel-list-item,
|
||||
#footer button {
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.channel-list-item:hover,
|
||||
#footer button:hover {
|
||||
background: rgba(43, 138, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.channel-list-item.active,
|
||||
#footer button.active {
|
||||
background: linear-gradient(90deg, rgba(43, 138, 255, 0.24), rgba(22, 40, 68, 0.92));
|
||||
box-shadow: inset 3px 0 0 var(--fc-gold);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.channel-list-item[data-type="lobby"] {
|
||||
color: #8bc3ff;
|
||||
}
|
||||
|
||||
.channel-list-item .badge {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--fc-text-muted);
|
||||
}
|
||||
|
||||
.channel-list-item .badge.highlight {
|
||||
background: var(--fc-gold);
|
||||
color: #08111e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#footer {
|
||||
background: rgba(10, 22, 40, 0.92);
|
||||
border-top: 1px solid var(--fc-border);
|
||||
}
|
||||
|
||||
#viewport .lt,
|
||||
#viewport .rt,
|
||||
#chat button.close,
|
||||
#chat button.menu,
|
||||
#chat button.mentions,
|
||||
#chat button.search,
|
||||
#form #submit,
|
||||
#form #upload,
|
||||
.password-container .reveal-password span {
|
||||
color: #8bc3ff;
|
||||
}
|
||||
|
||||
#viewport .lt:hover,
|
||||
#viewport .rt:hover,
|
||||
#chat button.close:hover,
|
||||
#chat button.menu:hover,
|
||||
#chat button.mentions:hover,
|
||||
#chat button.search:hover,
|
||||
#form #submit:hover,
|
||||
#form #upload:hover,
|
||||
.password-container .reveal-password span:hover {
|
||||
background: rgba(43, 138, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#chat .header {
|
||||
background: linear-gradient(135deg, #0e1e36, #1a3a6a, #2b8aff);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.header .title,
|
||||
.header .topic,
|
||||
#chat .header button {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
#chat .messages {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(17, 29, 51, 0.98), rgba(11, 21, 39, 0.98)),
|
||||
radial-gradient(circle at top right, rgba(43, 138, 255, 0.08), transparent 35%);
|
||||
}
|
||||
|
||||
#chat .msg {
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#chat .msg:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
#chat .chat-view[data-type="channel"] .msg.highlight,
|
||||
.mentions-popup .msg .content {
|
||||
background: linear-gradient(90deg, rgba(43, 138, 255, 0.16), rgba(255, 179, 0, 0.1));
|
||||
box-shadow: inset 3px 0 0 var(--fc-gold);
|
||||
}
|
||||
|
||||
#chat .msg-statusmsg {
|
||||
background: rgba(255, 179, 0, 0.18);
|
||||
color: #ffe4a0;
|
||||
}
|
||||
|
||||
#chat .msg[data-type="monospace_block"] .text {
|
||||
background: rgba(8, 17, 30, 0.94);
|
||||
border: 1px solid rgba(43, 138, 255, 0.22);
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 3px 0 0 var(--fc-gold);
|
||||
color: var(--fc-text);
|
||||
display: inline-block;
|
||||
line-height: 1.7;
|
||||
max-width: min(100%, 44rem);
|
||||
padding: 10px 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#chat .msg[data-command="motd"] .text {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(14, 30, 54, 0.96), rgba(8, 17, 30, 0.96)),
|
||||
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
|
||||
border-color: rgba(255, 179, 0, 0.28);
|
||||
color: #f4f8ff;
|
||||
}
|
||||
|
||||
#chat .msg[data-command="motd"] .from {
|
||||
color: #9dd3ff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#chat .msg[data-command="motd"] a {
|
||||
color: #8bc3ff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#chat .userlist,
|
||||
#form,
|
||||
.mentions-popup,
|
||||
.textcomplete-menu,
|
||||
#context-menu {
|
||||
background: rgba(17, 29, 51, 0.98);
|
||||
border-color: var(--fc-border);
|
||||
}
|
||||
|
||||
#chat .userlist .count,
|
||||
#chat .user-mode:before {
|
||||
background: rgba(8, 17, 30, 0.95);
|
||||
}
|
||||
|
||||
.input,
|
||||
#connect input,
|
||||
#connect select,
|
||||
#settings input,
|
||||
#settings select,
|
||||
#settings textarea,
|
||||
#form #nick,
|
||||
#chat .userlist .search,
|
||||
form.message-search input,
|
||||
.jump-to-input .input,
|
||||
.password-container input {
|
||||
background: rgba(8, 17, 30, 0.72);
|
||||
border: 1px solid var(--fc-border);
|
||||
border-radius: 10px;
|
||||
color: var(--fc-text);
|
||||
}
|
||||
|
||||
#form {
|
||||
border-top: 1px solid var(--fc-border);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#form #nick {
|
||||
color: #8bc3ff;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
#form #input {
|
||||
margin: 0 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
::placeholder,
|
||||
.jump-to-input .input::placeholder,
|
||||
form.message-search input::placeholder {
|
||||
color: rgba(232, 237, 245, 0.45);
|
||||
}
|
||||
|
||||
.jump-to-input:before,
|
||||
#chat .count:before {
|
||||
color: rgba(232, 237, 245, 0.45);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(180deg, rgba(24, 62, 112, 0.28), rgba(14, 30, 54, 0.38));
|
||||
border-color: var(--fc-accent);
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
color: var(--fc-text);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.btn:hover,
|
||||
.btn:focus,
|
||||
.btn:disabled {
|
||||
background: linear-gradient(180deg, #2b8aff, #1c6fe3);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn:active,
|
||||
.btn:focus,
|
||||
.input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(43, 138, 255, 0.26);
|
||||
}
|
||||
|
||||
#version-checker,
|
||||
#settings .settings-sync-panel,
|
||||
#connect .connect-sasl-external {
|
||||
background: rgba(8, 17, 30, 0.78);
|
||||
border: 1px solid var(--fc-border);
|
||||
color: var(--fc-text);
|
||||
}
|
||||
|
||||
#version-checker.loading {
|
||||
border-left: 3px solid var(--fc-accent);
|
||||
color: #9dd3ff;
|
||||
}
|
||||
|
||||
#version-checker.up-to-date {
|
||||
border-left: 3px solid var(--fc-success);
|
||||
color: #87e9a9;
|
||||
}
|
||||
|
||||
#version-checker.new-packages,
|
||||
#version-checker.new-version {
|
||||
border-left: 3px solid var(--fc-gold);
|
||||
color: #ffe4a0;
|
||||
}
|
||||
|
||||
#version-checker.error,
|
||||
#settings .error,
|
||||
#sign-in .error {
|
||||
border-left: 3px solid var(--fc-danger);
|
||||
color: #ffb3b3;
|
||||
}
|
||||
|
||||
#upload-progressbar {
|
||||
box-shadow: 0 0 14px rgba(255, 179, 0, 0.75);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:hover {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:vertical {
|
||||
background: linear-gradient(180deg, rgba(43, 138, 255, 0.72), rgba(30, 58, 95, 0.95));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:vertical:active {
|
||||
background: linear-gradient(180deg, rgba(255, 179, 0, 0.85), rgba(43, 138, 255, 0.9));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
box-shadow: 0 0 28px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#chat .header {
|
||||
padding-right: 6px;
|
||||
}
|
||||
}
|
||||
bluejay-logo.svg: |
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<ellipse cx="14" cy="18" rx="10" ry="9" fill="#3B7BC0"/>
|
||||
<ellipse cx="12" cy="20" rx="6" ry="6" fill="#C8DFFF"/>
|
||||
<path d="M10 14 C8 10, 16 8, 22 12 C24 14, 22 18, 18 18 C14 18, 10 16, 10 14Z" fill="#2B5A8A"/>
|
||||
<path d="M12 12.5 L21 11" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
|
||||
<circle cx="22" cy="11" r="6" fill="#3B7BC0"/>
|
||||
<path d="M20 6 L22 1 L24 3 L22 7Z" fill="#2B5A8A"/>
|
||||
<path d="M21 5 L22.5 2 L23.5 4Z" fill="#5BA3FF"/>
|
||||
<path d="M16 14 C18 16, 22 16, 26 14" stroke="#0d1520" stroke-width="1.8" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="24" cy="10" r="2" fill="#fff"/>
|
||||
<circle cx="24.5" cy="9.5" r="1" fill="#0d1520"/>
|
||||
<path d="M27 11 L31 11.5 L27 13Z" fill="#364b6b"/>
|
||||
<path d="M4 16 L1 13 L3 17 L1 20 L5 18Z" fill="#2B5A8A"/>
|
||||
<circle cx="10" cy="23" r="2" fill="#FFB300"/>
|
||||
<circle cx="10" cy="23" r="1" fill="#FFCA40"/>
|
||||
</svg>
|
||||
bluejay-bg.svg: |
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
|
||||
<defs>
|
||||
<pattern id="hex" x="0" y="0" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||
<path d="M30 0 L60 15 L60 37 L30 52 L0 37 L0 15 Z" fill="none" stroke="rgba(43,138,255,0.04)" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="200" height="200" fill="url(#hex)"/>
|
||||
<circle cx="30" cy="0" r="1" fill="rgba(255,179,0,0.06)"/>
|
||||
<circle cx="90" cy="0" r="1" fill="rgba(255,179,0,0.06)"/>
|
||||
<circle cx="150" cy="0" r="1" fill="rgba(255,179,0,0.06)"/>
|
||||
<circle cx="0" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
|
||||
<circle cx="60" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
|
||||
<circle cx="120" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
|
||||
<circle cx="180" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
|
||||
</svg>
|
||||
---
|
||||
# UnrealIRCd configuration template (passwords replaced by placeholders)
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
@@ -178,6 +713,15 @@ data:
|
||||
server allow;
|
||||
}
|
||||
}
|
||||
ircd.motd: |
|
||||
- BlueJay IRC -
|
||||
Welcome to BlueJayIRC on iamworkin.lan.
|
||||
|
||||
Web IRC: https://webirc.iamworkin.lan
|
||||
Channels: #general, #ops, #alerts
|
||||
|
||||
Keep it keyboard-first, practical, and kind.
|
||||
Managed from bluejay-infra via ArgoCD.
|
||||
---
|
||||
# Anope configuration template (passwords replaced by placeholders)
|
||||
apiVersion: v1
|
||||
@@ -195,7 +739,7 @@ data:
|
||||
|
||||
uplink
|
||||
{
|
||||
host = "unrealircd.irc.svc.cluster.local"
|
||||
host = "unrealircd.irc.svc"
|
||||
port = 8067
|
||||
password = "__LINK_PASSWORD__"
|
||||
}
|
||||
@@ -444,6 +988,8 @@ metadata:
|
||||
app: unrealircd
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: unrealircd
|
||||
@@ -506,6 +1052,10 @@ spec:
|
||||
- name: injected-config
|
||||
mountPath: /app/conf/unrealircd.conf
|
||||
subPath: unrealircd.conf
|
||||
- name: unrealircd-config-template
|
||||
mountPath: /app/conf/ircd.motd
|
||||
subPath: ircd.motd
|
||||
readOnly: true
|
||||
- name: unrealircd-data
|
||||
mountPath: /app/data
|
||||
- name: irc-tls
|
||||
@@ -614,6 +1164,82 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: anope-data
|
||||
---
|
||||
# The Lounge web IRC Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: thelounge
|
||||
namespace: irc
|
||||
labels:
|
||||
app: thelounge
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: thelounge
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: thelounge
|
||||
spec:
|
||||
initContainers:
|
||||
- name: install-flowercore-theme
|
||||
image: ghcr.io/thelounge/thelounge:4.4.3
|
||||
command:
|
||||
- sh
|
||||
- -lc
|
||||
- |
|
||||
set -eu
|
||||
THELOUNGE_HOME=/var/opt/thelounge thelounge install file:/flowercore-theme
|
||||
volumeMounts:
|
||||
- name: thelounge-config
|
||||
mountPath: /var/opt/thelounge/config.js
|
||||
subPath: config.js
|
||||
- name: thelounge-packages
|
||||
mountPath: /var/opt/thelounge/packages
|
||||
- name: thelounge-flowercore-theme
|
||||
mountPath: /flowercore-theme
|
||||
containers:
|
||||
- name: thelounge
|
||||
image: ghcr.io/thelounge/thelounge:4.4.3
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
cpu: 50m
|
||||
limits:
|
||||
memory: 256Mi
|
||||
cpu: 250m
|
||||
volumeMounts:
|
||||
- name: thelounge-config
|
||||
mountPath: /var/opt/thelounge/config.js
|
||||
subPath: config.js
|
||||
- name: thelounge-packages
|
||||
mountPath: /var/opt/thelounge/packages
|
||||
volumes:
|
||||
- name: thelounge-config
|
||||
configMap:
|
||||
name: thelounge-config
|
||||
- name: thelounge-packages
|
||||
emptyDir: {}
|
||||
- name: thelounge-flowercore-theme
|
||||
configMap:
|
||||
name: thelounge-flowercore-theme
|
||||
---
|
||||
# UnrealIRCd Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
@@ -648,6 +1274,20 @@ spec:
|
||||
targetPort: 8067
|
||||
name: services-link
|
||||
---
|
||||
# The Lounge web IRC Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: thelounge
|
||||
namespace: irc
|
||||
spec:
|
||||
selector:
|
||||
app: thelounge
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
name: http
|
||||
---
|
||||
# Traefik IngressRouteTCP - IRC plain (6667)
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRouteTCP
|
||||
@@ -679,3 +1319,21 @@ spec:
|
||||
port: 6697
|
||||
tls:
|
||||
passthrough: true
|
||||
---
|
||||
# Traefik IngressRoute - The Lounge web IRC
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: webirc
|
||||
namespace: irc
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`webirc.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: thelounge
|
||||
port: 9000
|
||||
tls:
|
||||
secretName: webirc-tls
|
||||
|
||||
165
apps/knowledge/README.md
Normal file
165
apps/knowledge/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# knowledge — FlowerCore.Knowledge.Web (Phase 2.4 K8s deploy)
|
||||
|
||||
**Status:** **LIVE 2026-04-27** at `https://knowledge.iamworkin.lan` —
|
||||
Phase 2.4 closed. Pod running, certificate issued (step-ca-acme), PVC
|
||||
bound (Longhorn 20Gi RWO), ArgoCD `infra-knowledge` synced. `/healthz`
|
||||
returns 200, `/api/v1/editions` returns `[]` (initial-deploy state — no
|
||||
*.db files in the PVC yet; Phase 2.5+ admin UI handles bulk
|
||||
population). Phase 1 of the Agent Zero MCP rollout keeps `/healthz`
|
||||
anonymous and gates `/mcp` behind `Authorization: Bearer <token>` built
|
||||
from the 1Password item `FlowerCore Knowledge MCP Tokens`.
|
||||
|
||||
- Plan: [`../../../FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md`](../../../FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md)
|
||||
- Sprint: [`../../../FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md`](../../../FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md) (Track B)
|
||||
- Repo: `D:\git\FlowerCore\FlowerCore.Knowledge\` (private GitHub repo,
|
||||
bootstrapped Sprint D batch 35)
|
||||
|
||||
`FlowerCore.Knowledge.Web` is the fleet-wide vector-indexing & RAG hub —
|
||||
a REST + MCP service that scans `*.db` files under
|
||||
`/data/vector-stores` and exposes per-edition reachability + corpus
|
||||
search to the rest of the FC ecosystem (Agent Zero, Chat.Web persona
|
||||
memory, AiStation embeddings explorer, TtsReader chapter context, BMO
|
||||
bot, Pi nodes via `fc-index sync`).
|
||||
|
||||
Phase 1 MCP routing is explicit:
|
||||
|
||||
- in-cluster Agent Zero → `http://knowledge-web.knowledge.svc/mcp`
|
||||
- workstation Agent Zero → `https://knowledge.iamworkin.lan/mcp`
|
||||
- probe URL for both lanes → `/healthz`
|
||||
|
||||
## Deployment order (do NOT skip / reorder)
|
||||
|
||||
### 1. FlowerCore.DNS public A record — knowledge.iamworkin.lan -> 10.0.56.200
|
||||
|
||||
Required BEFORE the Certificate resource is created, or cert-manager
|
||||
HTTP-01 silently backs off ~2h. Memory: `feedback_pfsense_dns_required_for_acme`.
|
||||
|
||||
The canonical path is FlowerCore.DNS:
|
||||
|
||||
```bash
|
||||
curl -sk https://dns.iamworkin.lan/api/v1/servers
|
||||
# Find the pfSense serverId, then create the record using the host label only.
|
||||
|
||||
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"knowledge","type":"A","data":"10.0.56.200","ttl":300}'
|
||||
```
|
||||
|
||||
If FlowerCore.DNS provider writes are failing 502 with "pfSense
|
||||
diag_command.php response did not contain a `<pre>` block" (status as of
|
||||
Sprint E Track B authoring 2026-04-27), add the override manually via
|
||||
the pfSense web UI:
|
||||
|
||||
1. Log in to `https://10.0.56.1` as admin
|
||||
2. Services → DNS Resolver → General Settings → Host Overrides
|
||||
3. Add: Host=`knowledge`, Domain=`iamworkin.lan`, IP Address=`10.0.56.200`
|
||||
4. Save + Apply Changes
|
||||
|
||||
Verify resolution from anywhere on LAN:
|
||||
|
||||
```bash
|
||||
nslookup knowledge.iamworkin.lan 10.0.56.1
|
||||
# Expect: 10.0.56.200
|
||||
```
|
||||
|
||||
Or against FlowerCore.DNS once the provider is fixed:
|
||||
|
||||
```bash
|
||||
curl -sk "https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight?hostname=knowledge.iamworkin.lan"
|
||||
# Expect: "resolvable": true
|
||||
```
|
||||
|
||||
### 2. Build + import the image to ALL RKE2 nodes
|
||||
|
||||
Pods may schedule on any RKE2 worker (server, agent1, agent2). The
|
||||
Longhorn PVC accepts mounts from any node, so the image must be
|
||||
imported to all three. Memory:
|
||||
`feedback_rke2_image_import_targets_all_nodes` +
|
||||
`feedback_rke2_localhost_imagepullpolicy`.
|
||||
|
||||
```bash
|
||||
# From BLUEJAY-WS, in D:\git\FlowerCore\FlowerCore.Knowledge
|
||||
TAG="v$(date +%Y%m%d%H%M)"
|
||||
dotnet.exe publish -c Release -o deploy/app \
|
||||
src/FlowerCore.Knowledge.Web/FlowerCore.Knowledge.Web.csproj
|
||||
podman build -t localhost/fc-knowledge-web:$TAG -f deploy/Dockerfile.deploy deploy
|
||||
podman save localhost/fc-knowledge-web:$TAG -o /tmp/fc-knowledge-web.tar
|
||||
|
||||
# Import to all three RKE2 nodes
|
||||
for node in rke2-server rke2-agent1 rke2-agent2; do
|
||||
scp /tmp/fc-knowledge-web.tar $node:/tmp/
|
||||
ssh $node "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-knowledge-web.tar"
|
||||
done
|
||||
```
|
||||
|
||||
The repo's `scripts/deploy-knowledge.sh` automates this loop.
|
||||
|
||||
### 3. Bump the image tag + push
|
||||
|
||||
Edit `knowledge.yaml`, replace `localhost/fc-knowledge-web:v202604272200`
|
||||
with the tag from step 2, then:
|
||||
|
||||
```bash
|
||||
cd D:/git/FlowerCore/bluejay-infra
|
||||
python scripts/check-pfsense-dns.py # confirms the DNS preflight
|
||||
git add apps/knowledge/
|
||||
git commit -m "feat(knowledge): deploy Phase 2.4 K8s manifest"
|
||||
git push
|
||||
```
|
||||
|
||||
ArgoCD picks up within ~3 minutes and creates `infra-knowledge`.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
```bash
|
||||
fcadmin_ssh noc1 '
|
||||
kubectl -n argocd get application infra-knowledge
|
||||
kubectl -n knowledge get certificate,pod,pvc
|
||||
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://knowledge.iamworkin.lan/healthz
|
||||
curl -sk -m 8 https://knowledge.iamworkin.lan/api/v1/editions | jq
|
||||
'
|
||||
```
|
||||
|
||||
Expect: Certificate `Ready: True` within ~60s, `/healthz` HTTP 200,
|
||||
`/api/v1/editions` returns an empty array (no DBs in the PVC yet) on
|
||||
first deploy.
|
||||
|
||||
## Initial-deploy state and Phase 2.5 follow-up
|
||||
|
||||
The Longhorn PVC is empty on first deploy. Knowledge.Web's filesystem
|
||||
catalog will report zero editions until vector-store `*.db` files are
|
||||
pushed into `/data/vector-stores`. Initial population is a follow-up
|
||||
step (Phase 2.5+, Blazor admin UI's "Rebuild" button); for the first
|
||||
deploy the goal is just to prove the pod boots, `/healthz` returns 200,
|
||||
and the Traefik IngressRoute serves the Scalar UI.
|
||||
|
||||
To copy an existing local DB into the PVC (one-time, manual until
|
||||
Phase 2.5 admin UI lands):
|
||||
|
||||
```bash
|
||||
fcadmin_ssh noc1 '
|
||||
POD=$(kubectl -n knowledge get pod -l app=knowledge-web -o jsonpath="{.items[0].metadata.name}")
|
||||
kubectl -n knowledge cp /var/lib/flowercore/vector-stores/bluejay-ai.db $POD:/data/vector-stores/bluejay-ai.db
|
||||
'
|
||||
```
|
||||
|
||||
## Probes + middleware notes
|
||||
|
||||
- `/healthz` is mapped by `Controllers/HealthController.cs` (controller-based
|
||||
attribute route). Cheap — no DB, no dependencies.
|
||||
- Liveness uses `tcpSocket` as a defensive fallback in case future
|
||||
middleware accidentally gates `/healthz` behind auth (memory:
|
||||
`feedback_k8s_probes_behind_auth_middleware`).
|
||||
- `/openapi/v1.json` and `/scalar/v1` are wired by `UseFlowerCoreApi`.
|
||||
Per memory `feedback_k8s_probes_must_not_hit_openapi`, probes must NOT
|
||||
point at OpenAPI documents — the `MapOpenApi` call can be slow during
|
||||
cold startup.
|
||||
|
||||
## Resource sizing
|
||||
|
||||
- 256Mi memory request / 1Gi limit.
|
||||
- 100m CPU request / 1000m limit.
|
||||
- 20Gi Longhorn PVC initial — sufficient for the bluejay-ai 1.94Gi DB +
|
||||
fleet-pi-edge 352Mi + fleet-bmo-bot 141Mi + headroom. Resize via
|
||||
`kubectl -n knowledge edit pvc knowledge-vector-store` if growing
|
||||
past 15Gi.
|
||||
262
apps/knowledge/knowledge.yaml
Normal file
262
apps/knowledge/knowledge.yaml
Normal file
@@ -0,0 +1,262 @@
|
||||
# FlowerCore.Knowledge.Web — fleet vector indexing & RAG hub.
|
||||
#
|
||||
# Phase 2.4 of the Knowledge service plan. REST + MCP service that scans
|
||||
# *.db files under /data/vector-stores and exposes:
|
||||
# - REST: /api/v1/editions, /api/v1/corpus/search, /healthz
|
||||
# - MCP: list_editions, describe_edition, corpus_search
|
||||
# - Static OpenAPI/Scalar via UseFlowerCoreApi
|
||||
#
|
||||
# Architecture:
|
||||
# Plan: FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md
|
||||
# Sprint: FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md (Track B)
|
||||
# Repo: D:\git\FlowerCore\FlowerCore.Knowledge\
|
||||
# Shared: FlowerCore.Common -> FlowerCore.Shared.Indexing (chunkers, vector
|
||||
# stores, edition profiles, ICorpusSearchService facade)
|
||||
#
|
||||
# Deployment order (see apps/knowledge/README.md and the bluejay-infra/README.md
|
||||
# top-level checklist):
|
||||
# 1. FlowerCore.DNS public A record knowledge.iamworkin.lan -> 10.0.56.200
|
||||
# MUST exist BEFORE the Certificate is created, or cert-manager HTTP-01
|
||||
# backs off ~2h. Memory: feedback_pfsense_dns_required_for_acme.
|
||||
# 2. Build + import the image to ALL RKE2 nodes (server + both agents) since
|
||||
# the Pod uses a Longhorn PVC and may schedule anywhere.
|
||||
# Memory: feedback_rke2_localhost_imagepullpolicy.
|
||||
# 3. Bump the image tag in this file, git push.
|
||||
# 4. ArgoCD ApplicationSet picks up within ~3 minutes and creates
|
||||
# infra-knowledge.
|
||||
#
|
||||
# Initial-deploy state:
|
||||
# The Longhorn PVC is empty on first deploy. Knowledge.Web's filesystem
|
||||
# catalog will report zero editions until vector-store *.db files are
|
||||
# pushed into /data/vector-stores. Initial population is a follow-up step
|
||||
# (Phase 2.5+, Blazor admin UI's "Rebuild" button); for the first deploy
|
||||
# the goal is just to prove the pod boots, /healthz returns 200, and the
|
||||
# Traefik IngressRoute serves the Scalar UI.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: knowledge
|
||||
labels:
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
---
|
||||
# MCP bearer token for the read-only Agent Zero Phase 1 lane. The 1Password
|
||||
# item currently stores the raw token in its concealed PASSWORD field, which
|
||||
# the operator syncs into the namespaced Secret key `password`.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: knowledge-mcp-tokens
|
||||
namespace: knowledge
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: knowledge-vector-store
|
||||
namespace: knowledge
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: knowledge-web
|
||||
namespace: knowledge
|
||||
labels:
|
||||
app: knowledge-web
|
||||
app.kubernetes.io/name: knowledge-web
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 3
|
||||
# RWO Longhorn PVC blocks rolling updates (multi-attach error). Recreate
|
||||
# is the canonical pattern (memory: feedback_rwo_pvc_blocks_rolling).
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: knowledge-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: knowledge-web
|
||||
app.kubernetes.io/name: knowledge-web
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
fsGroup: 1654
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: web
|
||||
# Placeholder tag — bump to the image you built + imported to ALL
|
||||
# RKE2 nodes via scripts/deploy-knowledge.sh before applying.
|
||||
image: localhost/fc-knowledge-web:v20260429232635
|
||||
imagePullPolicy: Never
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
args:
|
||||
- |
|
||||
if [ -n "${KNOWLEDGE_MCP_BEARER_TOKEN:-}" ]; then
|
||||
export FlowerCore__Mcp__ApiKey__Key="Bearer ${KNOWLEDGE_MCP_BEARER_TOKEN}"
|
||||
fi
|
||||
exec dotnet FlowerCore.Knowledge.Web.dll
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
env:
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:8080"
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
|
||||
value: "false"
|
||||
# Vector-store directory + embedding model + edition profile dir.
|
||||
# Profile JSON is baked into the image at /home/app/editions via the
|
||||
# csproj Content-link from FlowerCore.Common/editions/.
|
||||
- name: Knowledge__VectorStoresDirectory
|
||||
value: "/data/vector-stores"
|
||||
- name: Knowledge__EmbeddingModel
|
||||
value: "nomic-embed-text"
|
||||
- name: Knowledge__DefaultLimit
|
||||
value: "5"
|
||||
- name: Knowledge__MaxLimit
|
||||
value: "50"
|
||||
- name: FlowerCore__Editions__ProfileDirectory
|
||||
value: "/home/app/editions"
|
||||
# Embed via edge1 Pi 5 + AI HAT+ (10.0.57.17:11434). Cluster
|
||||
# services do not depend on BLUEJAY-WS (private dev hardware) per
|
||||
# bluejay-infra@0f9d56e. Query-time embedding is fast enough on
|
||||
# edge1 (~ms per query); bulk index rebuilds (Phase 2.5+) will
|
||||
# need a separate ingestion lane that can opt into the
|
||||
# workstation GPU when present.
|
||||
- name: FlowerCore__Ollama__BaseUrl
|
||||
value: "http://10.0.57.17:11434"
|
||||
- name: FlowerCore__Mcp__ApiKey__Key
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: knowledge-mcp-tokens
|
||||
key: password
|
||||
- name: FlowerCore__Mcp__ApiKey__HeaderName
|
||||
value: "Authorization"
|
||||
- name: KNOWLEDGE_MCP_BEARER_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: knowledge-mcp-tokens
|
||||
key: password
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
# /healthz is mapped by HealthController (controller-based route).
|
||||
# tcpSocket liveness is the defensive fallback in case middleware
|
||||
# later gates /healthz behind auth (memory:
|
||||
# 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: vector-store
|
||||
mountPath: /data/vector-stores
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: logs
|
||||
mountPath: /home/app/logs
|
||||
volumes:
|
||||
- name: vector-store
|
||||
persistentVolumeClaim:
|
||||
claimName: knowledge-vector-store
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: knowledge-web
|
||||
namespace: knowledge
|
||||
labels:
|
||||
app: knowledge-web
|
||||
app.kubernetes.io/name: knowledge-web
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: knowledge-web
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: knowledge-tls
|
||||
namespace: knowledge
|
||||
spec:
|
||||
secretName: knowledge-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- knowledge.iamworkin.lan
|
||||
duration: 2160h # 90d
|
||||
renewBefore: 720h # 30d
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: knowledge
|
||||
namespace: knowledge
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`knowledge.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: knowledge-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: knowledge-tls
|
||||
7
apps/knowledge/kustomization.yaml
Normal file
7
apps/knowledge/kustomization.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# ArgoCD's bluejay-infra ApplicationSet uses a directory generator and does
|
||||
# not require kustomization.yaml. Mirrors the fc-distribution shape so
|
||||
# `kubectl kustomize` previews work from a working copy.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- knowledge.yaml
|
||||
@@ -79,9 +79,15 @@ metadata:
|
||||
namespace: matrix
|
||||
labels:
|
||||
app: matrix-postgres
|
||||
argocd.argoproj.io/instance: infra-matrix
|
||||
spec:
|
||||
persistentVolumeClaimRetentionPolicy:
|
||||
whenDeleted: Retain
|
||||
whenScaled: Retain
|
||||
podManagementPolicy: OrderedReady
|
||||
serviceName: matrix-postgres
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 10
|
||||
selector:
|
||||
matchLabels:
|
||||
app: matrix-postgres
|
||||
@@ -137,9 +143,14 @@ spec:
|
||||
name: matrix-postgres-data
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
volumeMode: Filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
updateStrategy:
|
||||
rollingUpdate:
|
||||
partition: 0
|
||||
type: RollingUpdate
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
249
apps/monitoring/grafana-dashboard-remotedesktop.yaml
Normal file
249
apps/monitoring/grafana-dashboard-remotedesktop.yaml
Normal file
@@ -0,0 +1,249 @@
|
||||
# Grafana dashboard ConfigMap for FlowerCore.RemoteDesktop.
|
||||
#
|
||||
# Inlines the JSON from flowercore-remotedesktop-grafana-dashboard.json.
|
||||
# Kept as a standalone file (not inlined in noc-monitoring.yaml) so the
|
||||
# CRLF-dirty state of noc-monitoring.yaml doesn't have to be normalized
|
||||
# in the same pass. To actually load the dashboard, the Grafana Deployment
|
||||
# in noc-monitoring.yaml needs a matching 'volumes:' entry:
|
||||
#
|
||||
# - name: dashboard-remotedesktop
|
||||
# configMap:
|
||||
# name: grafana-dashboard-remotedesktop
|
||||
#
|
||||
# ArgoCD will sync this ConfigMap automatically through the bluejay-infra
|
||||
# ApplicationSet (infra-monitoring App). The dashboard just won't load
|
||||
# until the Grafana Deployment mount is wired.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: grafana-dashboard-remotedesktop
|
||||
namespace: monitoring
|
||||
data:
|
||||
remotedesktop.json: |
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (event) (increase(fc_desktop_session_events_total[$__rate_interval]))",
|
||||
"legendFormat": "{{event}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "RemoteDesktop Session Events",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showUnfilled": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (template, event) (increase(fc_desktop_session_events_total[24h]))",
|
||||
"legendFormat": "{{template}} {{event}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "24h Session Events By Template",
|
||||
"type": "bargauge"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"editorMode": "code",
|
||||
"expr": "fc_desktop_pool_ready",
|
||||
"legendFormat": "{{template}} ready",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"editorMode": "code",
|
||||
"expr": "fc_desktop_pool_desired",
|
||||
"legendFormat": "{{template}} desired",
|
||||
"range": true,
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Warm Pool Ready vs Desired",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(fc_desktop_session_events_total{event=\"connect\",browser_datasource=\"json\"}[24h])) - sum(increase(fc_desktop_session_events_total{event=\"disconnect\"}[24h]))",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "24h Connect Minus Disconnect",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"flowercore",
|
||||
"remotedesktop",
|
||||
"guacamole"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timezone": "browser",
|
||||
"title": "FlowerCore RemoteDesktop",
|
||||
"uid": "flowercore-remotedesktop",
|
||||
"version": 1
|
||||
}
|
||||
4420
apps/monitoring/noc-monitoring.yaml
Normal file
4420
apps/monitoring/noc-monitoring.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -219,6 +219,65 @@ spec:
|
||||
tls:
|
||||
secretName: cockpit-tls
|
||||
---
|
||||
# ============================================================
|
||||
# PuppetDB Dashboard - noc1:8080 (HTTP, web UI only)
|
||||
# Agent-to-PuppetDB mTLS still uses port 8081 directly via Puppet CA
|
||||
# (NOT via this proxy). See docs/infrastructure/cert-recovery-2026-04-28.md
|
||||
# ============================================================
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: puppetdb-external
|
||||
namespace: noc-proxy
|
||||
spec:
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
name: http
|
||||
clusterIP: None
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Endpoints
|
||||
metadata:
|
||||
name: puppetdb-external
|
||||
namespace: noc-proxy
|
||||
subsets:
|
||||
- addresses:
|
||||
- ip: 10.0.56.10
|
||||
ports:
|
||||
- port: 8080
|
||||
name: http
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: puppetdb-tls
|
||||
namespace: noc-proxy
|
||||
spec:
|
||||
secretName: puppetdb-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- puppetdb.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: puppetdb
|
||||
namespace: noc-proxy
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- kind: Rule
|
||||
match: Host(`puppetdb.iamworkin.lan`)
|
||||
services:
|
||||
- name: puppetdb-external
|
||||
port: 8080
|
||||
tls:
|
||||
secretName: puppetdb-tls
|
||||
---
|
||||
# NetworkPolicy: allow Traefik ingress, allow egress to noc1
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
@@ -242,6 +301,8 @@ spec:
|
||||
ports:
|
||||
- port: 3000
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
- port: 9090
|
||||
protocol: TCP
|
||||
- port: 9091
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# FlowerCore.Telephony - Blazor Server + REST API + Twilio IVR
|
||||
# ArgoCD managed - BlueJay Lab
|
||||
# Credentials: 1Password → OnePasswordItem CRD → K8s Secret (twilio-credentials)
|
||||
# TTS: Piper on edge1 (10.0.57.15:8500)
|
||||
# TTS: Piper on edge1 (10.0.57.17:8500) — endpoint /tts with {"text":"..."}
|
||||
# Public: telephony.flowercore.io via Cloudflare origin cert
|
||||
---
|
||||
apiVersion: v1
|
||||
@@ -48,7 +48,7 @@ data:
|
||||
"StatusCallbackUrl": "https://telephony.flowercore.io/api/twilio/webhooks/voice/status"
|
||||
},
|
||||
"Asterisk": {
|
||||
"BaseUrl": "http://localhost:8088",
|
||||
"BaseUrl": "http://10.0.56.12:8088",
|
||||
"Username": "flowercore",
|
||||
"Password": "bluejay-asterisk-ari",
|
||||
"Application": "flowercore-pbx",
|
||||
@@ -57,15 +57,20 @@ data:
|
||||
}
|
||||
},
|
||||
"Ari": {
|
||||
"BaseUrl": "http://localhost:8088",
|
||||
"BaseUrl": "http://10.0.56.12:8088",
|
||||
"Username": "flowercore",
|
||||
"Password": "bluejay-asterisk-ari",
|
||||
"Application": "flowercore-pbx",
|
||||
"ReconnectDelaySeconds": 5,
|
||||
"MaxReconnectDelaySeconds": 60
|
||||
},
|
||||
"Sip": {
|
||||
"Domain": "10.0.56.207",
|
||||
"Port": 5060,
|
||||
"Transport": "udp"
|
||||
},
|
||||
"Tts": {
|
||||
"PiperUrl": "http://10.0.57.15:8500",
|
||||
"PiperUrl": "http://10.0.57.17:8500",
|
||||
"DefaultEngine": "piper",
|
||||
"SampleRate": 8000
|
||||
},
|
||||
@@ -113,14 +118,25 @@ spec:
|
||||
app: telephony-web
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1654
|
||||
runAsGroup: 1654
|
||||
fsGroup: 1654
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: rke2-agent1
|
||||
initContainers:
|
||||
- name: fix-data-perms
|
||||
image: busybox:latest
|
||||
command: ["sh", "-c", "chown -R 1654:1654 /data"]
|
||||
# Also chown /shared-tts (hostPath /tmp/tts-audio) so the non-root
|
||||
# app user (uid 1654) can write Piper .sln16 files that Asterisk
|
||||
# reads at /var/lib/asterisk/sounds/tts. World-readable (755) is
|
||||
# fine — Asterisk runs as a different uid in the other pod.
|
||||
command: ["sh", "-c", "chown -R 1654:1654 /data && chown 1654:1654 /shared-tts && chmod 0755 /shared-tts"]
|
||||
volumeMounts:
|
||||
- name: telephony-data
|
||||
mountPath: /data
|
||||
- name: shared-tts
|
||||
mountPath: /shared-tts
|
||||
hostNetwork: true
|
||||
dnsPolicy: ClusterFirstWithHostNet
|
||||
affinity:
|
||||
@@ -132,8 +148,13 @@ spec:
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: telephony-web
|
||||
image: localhost/fc-telephony-web:latest
|
||||
image: localhost/fc-telephony-web:v202604252156
|
||||
imagePullPolicy: Never
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
ports:
|
||||
- containerPort: 5100
|
||||
name: http
|
||||
@@ -163,6 +184,15 @@ spec:
|
||||
readOnly: true
|
||||
- name: telephony-data
|
||||
mountPath: /data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
# Shared TTS audio — we write Piper .sln16 output here; Asterisk
|
||||
# pod reads the same hostPath at /var/lib/asterisk/sounds/tts and
|
||||
# plays via `sound:tts/<name>`. Both pods are pinned to rke2-agent1.
|
||||
- name: shared-tts
|
||||
mountPath: /shared-tts
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
@@ -189,6 +219,14 @@ spec:
|
||||
- name: telephony-data
|
||||
persistentVolumeClaim:
|
||||
claimName: telephony-data
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
- name: shared-tts
|
||||
hostPath:
|
||||
path: /tmp/tts-audio
|
||||
type: DirectoryOrCreate
|
||||
---
|
||||
# ClusterIP service
|
||||
apiVersion: v1
|
||||
@@ -275,10 +313,10 @@ spec:
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# Allow Piper TTS on edge1 (10.0.57.15:8500)
|
||||
# Allow Piper TTS on edge1 (10.0.57.17:8500)
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.57.15/32
|
||||
cidr: 10.0.57.17/32
|
||||
ports:
|
||||
- port: 8500
|
||||
protocol: TCP
|
||||
|
||||
96
apps/zabbix/templates/flowercore-print-ollama.yaml
Normal file
96
apps/zabbix/templates/flowercore-print-ollama.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
zabbix_export:
|
||||
version: '7.2'
|
||||
template_groups:
|
||||
- uuid: 30a90fb5fb3e4a7f9bb4517022c7726a
|
||||
name: Templates/FlowerCore
|
||||
templates:
|
||||
- uuid: 89cecb27144c4b539bd8972d4d949063
|
||||
template: FlowerCore Print Ollama
|
||||
name: FlowerCore Print Ollama
|
||||
description: FlowerCore Print.Web Ollama health probe checks. Import this template into Zabbix and link it to the Print.Web host.
|
||||
groups:
|
||||
- name: Templates/FlowerCore
|
||||
items:
|
||||
- uuid: 8fd2720255d54bc8bda0fe3ab4677c6c
|
||||
name: Print.Web metrics snapshot
|
||||
type: HTTP_AGENT
|
||||
key: flowercore.print.ollama.snapshot
|
||||
delay: 30s
|
||||
history: 7d
|
||||
trends: '0'
|
||||
value_type: TEXT
|
||||
url: http://10.0.57.16:5200/api/metrics
|
||||
timeout: 5s
|
||||
description: Raw JSON from Print.Web GET /api/metrics. The Ollama summary is public monitoring data; /api/ai/ollama-snapshot remains API-key protected.
|
||||
- uuid: 5cb902556e9f45c2b4c29c5c4a32fd73
|
||||
name: Print.Web Ollama long keep-alive runner count
|
||||
type: DEPENDENT
|
||||
key: flowercore.print.ollama.long_keepalive.count
|
||||
delay: '0'
|
||||
history: 7d
|
||||
trends: 30d
|
||||
value_type: UNSIGNED
|
||||
description: Number of active Ollama runners whose keep-alive window remains above 10 minutes.
|
||||
preprocessing:
|
||||
- type: JAVASCRIPT
|
||||
parameters:
|
||||
- |
|
||||
var payload = JSON.parse(value);
|
||||
var ollama = payload.ollama || payload.Ollama || {};
|
||||
var runners = ollama.runners || ollama.Runners || [];
|
||||
if (!Array.isArray(runners)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
for (var i = 0; i < runners.length; i += 1) {
|
||||
var runner = runners[i] || {};
|
||||
var markedLong = runner.longKeepAlive || runner.LongKeepAlive;
|
||||
var remainingRaw = runner.keepAliveRemainingSeconds;
|
||||
if (remainingRaw === undefined || remainingRaw === null) {
|
||||
remainingRaw = runner.KeepAliveRemainingSeconds;
|
||||
}
|
||||
var remaining = Number(remainingRaw || 0);
|
||||
if (markedLong === true || remaining > 600) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
master_item:
|
||||
key: flowercore.print.ollama.snapshot
|
||||
- uuid: 73680dcbbe4844f48378c9f3042641f1
|
||||
name: Print.Web Ollama active runner count
|
||||
type: DEPENDENT
|
||||
key: flowercore.print.ollama.active_runner.count
|
||||
delay: '0'
|
||||
history: 7d
|
||||
trends: 30d
|
||||
value_type: UNSIGNED
|
||||
description: Active runner count from the Print.Web Ollama snapshot.
|
||||
preprocessing:
|
||||
- type: JAVASCRIPT
|
||||
parameters:
|
||||
- |
|
||||
var payload = JSON.parse(value);
|
||||
var ollama = payload.ollama || payload.Ollama || {};
|
||||
var activeRunnerRaw = ollama.activeRunnerCount;
|
||||
if (activeRunnerRaw === undefined || activeRunnerRaw === null) {
|
||||
activeRunnerRaw = ollama.ActiveRunnerCount;
|
||||
}
|
||||
var activeRunnerCount = Number(activeRunnerRaw);
|
||||
if (!isNaN(activeRunnerCount)) {
|
||||
return activeRunnerCount;
|
||||
}
|
||||
|
||||
var runners = ollama.runners || ollama.Runners || [];
|
||||
return Array.isArray(runners) ? runners.length : 0;
|
||||
master_item:
|
||||
key: flowercore.print.ollama.snapshot
|
||||
triggers:
|
||||
- uuid: 8fcd85b7e6e9423099b5e2bcbba3537e
|
||||
expression: last(/FlowerCore Print Ollama/flowercore.print.ollama.long_keepalive.count)>0
|
||||
name: Print.Web Ollama runner keep-alive exceeds 10 minutes
|
||||
priority: WARNING
|
||||
description: Print.Web reports at least one active Ollama runner with more than 10 minutes of keep-alive remaining. Check the Admin Ollama Fleet panel and stop duplicate model callers before the Pi 5 Ollama lane thrashes.
|
||||
manual_close: 'YES'
|
||||
174
apps/zabbix/templates/flowercore-remotedesktop.yaml
Normal file
174
apps/zabbix/templates/flowercore-remotedesktop.yaml
Normal file
@@ -0,0 +1,174 @@
|
||||
zabbix_export:
|
||||
version: '7.2'
|
||||
template_groups:
|
||||
- uuid: 2ce6df1168bd4797aa5374fd19438746
|
||||
name: Templates/FlowerCore
|
||||
templates:
|
||||
- uuid: 5b20d8f9d3c346f7b1c7fe6922e9d4d1
|
||||
template: FlowerCore RemoteDesktop
|
||||
name: FlowerCore RemoteDesktop
|
||||
description: Optional RemoteDesktop observability import. This template reads the Prometheus exposition from FlowerCore.RemoteDesktop and extracts launch/connect/disconnect/recording counters plus warm-pool gauges. Adjust the metrics URL if the Zabbix host should scrape a different endpoint than the public desktop host.
|
||||
groups:
|
||||
- name: Templates/FlowerCore
|
||||
items:
|
||||
- uuid: 357ab8ec721a4d31a5488bdd60a6679d
|
||||
name: RemoteDesktop metrics snapshot
|
||||
type: HTTP_AGENT
|
||||
key: flowercore.remotedesktop.metrics
|
||||
delay: 30s
|
||||
history: 7d
|
||||
trends: '0'
|
||||
value_type: TEXT
|
||||
url: https://desktop.iamworkin.lan/metrics
|
||||
timeout: 10s
|
||||
description: Raw Prometheus exposition from FlowerCore.RemoteDesktop.
|
||||
- uuid: 59af4d77fbb54dc6a733f8dc86d73c3d
|
||||
name: RemoteDesktop launch events total
|
||||
type: DEPENDENT
|
||||
key: flowercore.remotedesktop.launch.total
|
||||
delay: '0'
|
||||
history: 30d
|
||||
trends: 365d
|
||||
value_type: FLOAT
|
||||
preprocessing:
|
||||
- type: JAVASCRIPT
|
||||
parameters:
|
||||
- |
|
||||
var lines = String(value || '').split(/\r?\n/);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < lines.length; i += 1) {
|
||||
var line = lines[i];
|
||||
if (line.indexOf('fc_desktop_session_events_total{') !== 0 || line.indexOf('event="launch"') === -1) {
|
||||
continue;
|
||||
}
|
||||
var parts = line.trim().split(/\s+/);
|
||||
var metricValue = Number(parts[parts.length - 1]);
|
||||
if (!isNaN(metricValue)) {
|
||||
sum += metricValue;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
master_item:
|
||||
key: flowercore.remotedesktop.metrics
|
||||
- uuid: 479e5d87f8e14e9cb4c45f1832723a34
|
||||
name: RemoteDesktop connect events total (json datasource)
|
||||
type: DEPENDENT
|
||||
key: flowercore.remotedesktop.connect.json.total
|
||||
delay: '0'
|
||||
history: 30d
|
||||
trends: 365d
|
||||
value_type: FLOAT
|
||||
preprocessing:
|
||||
- type: JAVASCRIPT
|
||||
parameters:
|
||||
- |
|
||||
var lines = String(value || '').split(/\r?\n/);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < lines.length; i += 1) {
|
||||
var line = lines[i];
|
||||
if (line.indexOf('fc_desktop_session_events_total{') !== 0
|
||||
|| line.indexOf('event="connect"') === -1
|
||||
|| line.indexOf('browser_datasource="json"') === -1) {
|
||||
continue;
|
||||
}
|
||||
var parts = line.trim().split(/\s+/);
|
||||
var metricValue = Number(parts[parts.length - 1]);
|
||||
if (!isNaN(metricValue)) {
|
||||
sum += metricValue;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
master_item:
|
||||
key: flowercore.remotedesktop.metrics
|
||||
- uuid: 8ad073699ca74a99ab36ef1e4a4b06b8
|
||||
name: RemoteDesktop disconnect events total
|
||||
type: DEPENDENT
|
||||
key: flowercore.remotedesktop.disconnect.total
|
||||
delay: '0'
|
||||
history: 30d
|
||||
trends: 365d
|
||||
value_type: FLOAT
|
||||
preprocessing:
|
||||
- type: JAVASCRIPT
|
||||
parameters:
|
||||
- |
|
||||
var lines = String(value || '').split(/\r?\n/);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < lines.length; i += 1) {
|
||||
var line = lines[i];
|
||||
if (line.indexOf('fc_desktop_session_events_total{') !== 0 || line.indexOf('event="disconnect"') === -1) {
|
||||
continue;
|
||||
}
|
||||
var parts = line.trim().split(/\s+/);
|
||||
var metricValue = Number(parts[parts.length - 1]);
|
||||
if (!isNaN(metricValue)) {
|
||||
sum += metricValue;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
master_item:
|
||||
key: flowercore.remotedesktop.metrics
|
||||
- uuid: 0a50ab8cd4ab4c97ac52f3d94b02ff8f
|
||||
name: RemoteDesktop recording events total
|
||||
type: DEPENDENT
|
||||
key: flowercore.remotedesktop.recording.total
|
||||
delay: '0'
|
||||
history: 30d
|
||||
trends: 365d
|
||||
value_type: FLOAT
|
||||
preprocessing:
|
||||
- type: JAVASCRIPT
|
||||
parameters:
|
||||
- |
|
||||
var lines = String(value || '').split(/\r?\n/);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < lines.length; i += 1) {
|
||||
var line = lines[i];
|
||||
if (line.indexOf('fc_desktop_session_events_total{') !== 0 || line.indexOf('event="recording"') === -1) {
|
||||
continue;
|
||||
}
|
||||
var parts = line.trim().split(/\s+/);
|
||||
var metricValue = Number(parts[parts.length - 1]);
|
||||
if (!isNaN(metricValue)) {
|
||||
sum += metricValue;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
master_item:
|
||||
key: flowercore.remotedesktop.metrics
|
||||
- uuid: 5d4d5e7b38d14c68a72877e37d7f1bde
|
||||
name: RemoteDesktop warm pools ready
|
||||
type: DEPENDENT
|
||||
key: flowercore.remotedesktop.pool.ready
|
||||
delay: '0'
|
||||
history: 30d
|
||||
trends: 365d
|
||||
value_type: FLOAT
|
||||
preprocessing:
|
||||
- type: JAVASCRIPT
|
||||
parameters:
|
||||
- |
|
||||
var lines = String(value || '').split(/\r?\n/);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < lines.length; i += 1) {
|
||||
var line = lines[i];
|
||||
if (line.indexOf('fc_desktop_pool_ready{') !== 0) {
|
||||
continue;
|
||||
}
|
||||
var parts = line.trim().split(/\s+/);
|
||||
var metricValue = Number(parts[parts.length - 1]);
|
||||
if (!isNaN(metricValue)) {
|
||||
sum += metricValue;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
master_item:
|
||||
key: flowercore.remotedesktop.metrics
|
||||
valuemaps: []
|
||||
triggers:
|
||||
- uuid: 5ef71c752fa94d2e8ce3ced79fcfe0f4
|
||||
expression: nodata(/FlowerCore RemoteDesktop/flowercore.remotedesktop.metrics,10m)=1
|
||||
name: FlowerCore RemoteDesktop metrics unavailable
|
||||
priority: WARNING
|
||||
description: FlowerCore.RemoteDesktop /metrics has not returned data for 10 minutes. Check the web deployment, ingress, or the scrape URL configured in this template.
|
||||
manual_close: 'YES'
|
||||
@@ -18,9 +18,15 @@ metadata:
|
||||
namespace: zabbix
|
||||
labels:
|
||||
app: zabbix-postgres
|
||||
argocd.argoproj.io/instance: infra-zabbix
|
||||
spec:
|
||||
persistentVolumeClaimRetentionPolicy:
|
||||
whenDeleted: Retain
|
||||
whenScaled: Retain
|
||||
podManagementPolicy: OrderedReady
|
||||
serviceName: zabbix-postgres
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 10
|
||||
selector:
|
||||
matchLabels:
|
||||
app: zabbix-postgres
|
||||
@@ -99,9 +105,14 @@ spec:
|
||||
name: zabbix-postgres-data
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
volumeMode: Filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
updateStrategy:
|
||||
rollingUpdate:
|
||||
partition: 0
|
||||
type: RollingUpdate
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
399
scripts/check-pfsense-dns.py
Normal file
399
scripts/check-pfsense-dns.py
Normal file
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
check-pfsense-dns.py
|
||||
|
||||
Historical name retained for continuity, but the check now runs through the
|
||||
public FlowerCore.DNS preflight API instead of a raw local resolver lookup.
|
||||
|
||||
Fails if any *.iamworkin.lan hostname referenced by a cert-manager Certificate
|
||||
`spec.dnsNames` or a Traefik IngressRoute `Host(...)` match rule is NOT
|
||||
resolvable via FlowerCore.DNS:
|
||||
|
||||
GET /api/v1/zones/{zone}/resolve-preflight?hostname=<host>
|
||||
|
||||
Two sources are scanned:
|
||||
|
||||
1. apps/*/*.yaml in this bluejay-infra checkout — the pre-merge gate.
|
||||
2. Live-cluster Certificates + IngressRoutes (opt-in with --live, or auto when
|
||||
kubectl is on PATH AND kubeconfig is usable). This catches hostnames that
|
||||
exist in the running cluster but aren't (yet) tracked in bluejay-infra —
|
||||
e.g. services deployed via their own repo's deploy script. Retail.Web on
|
||||
2026-04-23 was stuck Issuing for 15h because of exactly this gap.
|
||||
|
||||
Run from anywhere that can reach the FlowerCore.DNS host:
|
||||
|
||||
python scripts/check-pfsense-dns.py # auto live scan if kubectl works
|
||||
python scripts/check-pfsense-dns.py --live # require live scan
|
||||
python scripts/check-pfsense-dns.py --no-live # manifests only (CI default)
|
||||
|
||||
Exit code 0: all referenced hosts pass FlowerCore.DNS preflight.
|
||||
Exit code 1: at least one host fails preflight.
|
||||
Exit code 2: --live requested but kubectl was unusable.
|
||||
|
||||
This is intentionally narrow: it only flags hostnames that cert-manager will
|
||||
actually try to validate or that Traefik will route. IRC server-link names,
|
||||
Docker image tags, comments, etc. are ignored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml # PyYAML
|
||||
except ImportError:
|
||||
sys.exit("PyYAML required: pip install pyyaml")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
APPS_DIR = REPO_ROOT / "apps"
|
||||
|
||||
HOST_RE = re.compile(r"Host\(`([^`]+)`\)")
|
||||
|
||||
LIVE_SOURCE = "live-cluster"
|
||||
DEFAULT_BASE_URL = os.environ.get("FLOWERCORE_DNS_BASE_URL", "https://dns.iamworkin.lan")
|
||||
DEFAULT_ZONE = os.environ.get("FLOWERCORE_DNS_ZONE", "iamworkin.lan")
|
||||
DEFAULT_TIMEOUT_SECONDS = float(os.environ.get("FLOWERCORE_DNS_TIMEOUT_SECONDS", "20"))
|
||||
DEFAULT_WORKERS = max(1, int(os.environ.get("FLOWERCORE_DNS_WORKERS", "8")))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreflightResult:
|
||||
host: str
|
||||
ok: bool
|
||||
resolved_zone: str | None
|
||||
server_name: str | None
|
||||
provider: str | None
|
||||
addresses: list[str]
|
||||
challenge_fqdn: str
|
||||
message: str
|
||||
|
||||
|
||||
def extract_hosts_from_doc(doc: dict) -> set[str]:
|
||||
"""Pull iamworkin.lan hostnames from a single K8s manifest doc."""
|
||||
out: set[str] = set()
|
||||
if not isinstance(doc, dict):
|
||||
return out
|
||||
|
||||
kind = doc.get("kind", "")
|
||||
spec = doc.get("spec") or {}
|
||||
|
||||
if kind == "Certificate":
|
||||
for name in spec.get("dnsNames", []) or []:
|
||||
if isinstance(name, str) and name.endswith(".iamworkin.lan"):
|
||||
out.add(name)
|
||||
|
||||
elif kind == "IngressRoute":
|
||||
for route in spec.get("routes", []) or []:
|
||||
match = route.get("match", "") if isinstance(route, dict) else ""
|
||||
for h in HOST_RE.findall(match):
|
||||
if h.endswith(".iamworkin.lan"):
|
||||
out.add(h)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def collect_hosts_from_manifests() -> dict[str, list[str]]:
|
||||
"""hostname -> [list of manifest files that referenced it]."""
|
||||
index: dict[str, list[str]] = {}
|
||||
for path in sorted(APPS_DIR.rglob("*.yaml")):
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for doc in yaml.safe_load_all(f):
|
||||
for host in extract_hosts_from_doc(doc):
|
||||
index.setdefault(host, []).append(str(path.relative_to(REPO_ROOT)))
|
||||
except yaml.YAMLError as e:
|
||||
print(f"warn: could not parse {path}: {e}", file=sys.stderr)
|
||||
return index
|
||||
|
||||
|
||||
def _kubectl_json(args: list[str]) -> dict | None:
|
||||
"""Run `kubectl ... -o json` and return the parsed result, or None on failure."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["kubectl", *args, "-o", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(r.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def collect_hosts_from_cluster() -> tuple[dict[str, list[str]], bool]:
|
||||
"""
|
||||
Scan live cluster. Returns (host_index, ok).
|
||||
|
||||
ok=False means kubectl wasn't usable; the caller decides whether that's
|
||||
fatal (--live) or just a warning (auto mode).
|
||||
"""
|
||||
if not shutil.which("kubectl"):
|
||||
return {}, False
|
||||
|
||||
index: dict[str, list[str]] = {}
|
||||
|
||||
certs = _kubectl_json(["get", "certificate", "-A"])
|
||||
if certs is None:
|
||||
return {}, False
|
||||
for item in certs.get("items", []):
|
||||
meta = item.get("metadata", {})
|
||||
ns = meta.get("namespace", "?")
|
||||
name = meta.get("name", "?")
|
||||
ref = f"{LIVE_SOURCE} Certificate {ns}/{name}"
|
||||
for dn in (item.get("spec", {}) or {}).get("dnsNames", []) or []:
|
||||
if isinstance(dn, str) and dn.endswith(".iamworkin.lan"):
|
||||
index.setdefault(dn, []).append(ref)
|
||||
|
||||
irs = _kubectl_json(["get", "ingressroute", "-A"])
|
||||
if irs is not None:
|
||||
for item in irs.get("items", []):
|
||||
meta = item.get("metadata", {})
|
||||
ns = meta.get("namespace", "?")
|
||||
name = meta.get("name", "?")
|
||||
ref = f"{LIVE_SOURCE} IngressRoute {ns}/{name}"
|
||||
for route in (item.get("spec", {}) or {}).get("routes", []) or []:
|
||||
match = route.get("match", "") if isinstance(route, dict) else ""
|
||||
for h in HOST_RE.findall(match):
|
||||
if h.endswith(".iamworkin.lan"):
|
||||
index.setdefault(h, []).append(ref)
|
||||
|
||||
return index, True
|
||||
|
||||
|
||||
def _ssl_context(insecure: bool) -> ssl.SSLContext:
|
||||
return ssl._create_unverified_context() if insecure else ssl.create_default_context()
|
||||
|
||||
|
||||
def preflight_host(
|
||||
base_url: str,
|
||||
zone: str,
|
||||
host: str,
|
||||
timeout_seconds: float,
|
||||
insecure: bool,
|
||||
) -> PreflightResult:
|
||||
path = (
|
||||
f"/api/v1/zones/{urllib.parse.quote(zone, safe='')}/resolve-preflight"
|
||||
f"?hostname={urllib.parse.quote(host, safe='')}"
|
||||
)
|
||||
url = urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request,
|
||||
timeout=timeout_seconds,
|
||||
context=_ssl_context(insecure),
|
||||
) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace").strip()
|
||||
detail = body[:200] if body else exc.reason
|
||||
return PreflightResult(
|
||||
host=host,
|
||||
ok=False,
|
||||
resolved_zone=None,
|
||||
server_name=None,
|
||||
provider=None,
|
||||
addresses=[],
|
||||
challenge_fqdn=f"_acme-challenge.{host.rstrip('.')}.",
|
||||
message=f"HTTP {exc.code}: {detail}",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced as preflight failure detail
|
||||
return PreflightResult(
|
||||
host=host,
|
||||
ok=False,
|
||||
resolved_zone=None,
|
||||
server_name=None,
|
||||
provider=None,
|
||||
addresses=[],
|
||||
challenge_fqdn=f"_acme-challenge.{host.rstrip('.')}.",
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
resolved_zone = payload.get("resolvedZone")
|
||||
server_name = payload.get("serverName")
|
||||
provider = payload.get("provider")
|
||||
addresses = [value for value in payload.get("addresses", []) if isinstance(value, str)]
|
||||
supports_acme = bool(payload.get("supportsAcmeDns01"))
|
||||
resolvable = bool(payload.get("resolvable"))
|
||||
challenge_fqdn = str(payload.get("challengeFqdn", f"_acme-challenge.{host.rstrip('.')}."))
|
||||
message = str(payload.get("message", "")).strip()
|
||||
|
||||
if not supports_acme and not message:
|
||||
message = "Matched DNS server does not advertise ACME DNS-01 support."
|
||||
|
||||
ok = supports_acme and resolvable and bool(resolved_zone)
|
||||
return PreflightResult(
|
||||
host=host,
|
||||
ok=ok,
|
||||
resolved_zone=resolved_zone,
|
||||
server_name=server_name,
|
||||
provider=provider,
|
||||
addresses=addresses,
|
||||
challenge_fqdn=challenge_fqdn,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
def run_preflight(
|
||||
hosts: list[str],
|
||||
base_url: str,
|
||||
zone: str,
|
||||
timeout_seconds: float,
|
||||
insecure: bool,
|
||||
workers: int,
|
||||
) -> dict[str, PreflightResult]:
|
||||
if not hosts:
|
||||
return {}
|
||||
|
||||
max_workers = max(1, min(workers, len(hosts)))
|
||||
results: dict[str, PreflightResult] = {}
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
future_map = {
|
||||
pool.submit(preflight_host, base_url, zone, host, timeout_seconds, insecure): host
|
||||
for host in hosts
|
||||
}
|
||||
for future in as_completed(future_map):
|
||||
host = future_map[future]
|
||||
results[host] = future.result()
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[1] if __doc__ else None)
|
||||
parser.add_argument(
|
||||
"--live",
|
||||
dest="live",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Require a live-cluster scan (fail if kubectl unreachable).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-live",
|
||||
dest="live",
|
||||
action="store_false",
|
||||
help="Skip live-cluster scan (manifests only).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=DEFAULT_BASE_URL,
|
||||
help=f"FlowerCore.DNS base URL (default: {DEFAULT_BASE_URL}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--zone",
|
||||
default=DEFAULT_ZONE,
|
||||
help=f"Zone passed to resolve-preflight (default: {DEFAULT_ZONE}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-seconds",
|
||||
type=float,
|
||||
default=DEFAULT_TIMEOUT_SECONDS,
|
||||
help=f"Per-host resolve-preflight timeout (default: {DEFAULT_TIMEOUT_SECONDS}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=DEFAULT_WORKERS,
|
||||
help=f"Parallel preflight workers (default: {DEFAULT_WORKERS}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
help="Skip TLS verification when calling FlowerCore.DNS.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
hosts = collect_hosts_from_manifests()
|
||||
|
||||
live_requested = args.live is True
|
||||
live_auto = args.live is None
|
||||
|
||||
if live_requested or live_auto:
|
||||
live_hosts, live_ok = collect_hosts_from_cluster()
|
||||
if live_requested and not live_ok:
|
||||
print("ERROR: --live requested but kubectl is not available or auth failed.", file=sys.stderr)
|
||||
return 2
|
||||
if live_ok:
|
||||
before = len(hosts)
|
||||
for host, refs in live_hosts.items():
|
||||
hosts.setdefault(host, []).extend(refs)
|
||||
new_hosts = len(hosts) - before
|
||||
print(f"(live scan: {len(live_hosts)} cluster host(s); {new_hosts} not covered by manifests)")
|
||||
elif live_auto:
|
||||
print("(kubectl not reachable — skipping live scan; run from a workstation with cluster access to catch retail-style drift)")
|
||||
|
||||
if not hosts:
|
||||
print("No iamworkin.lan hostnames found in manifests or cluster — nothing to check.")
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"(preflight: {len(hosts)} host(s) via {args.base_url.rstrip('/')}"
|
||||
f"/api/v1/zones/{args.zone}/resolve-preflight)"
|
||||
)
|
||||
results = run_preflight(
|
||||
sorted(hosts),
|
||||
base_url=args.base_url,
|
||||
zone=args.zone,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
insecure=args.insecure,
|
||||
workers=args.workers,
|
||||
)
|
||||
|
||||
failed: list[tuple[str, list[str], PreflightResult]] = []
|
||||
for host in sorted(hosts):
|
||||
result = results[host]
|
||||
if result.ok:
|
||||
addresses = ", ".join(result.addresses) if result.addresses else "(no A/AAAA answers)"
|
||||
zone_label = result.resolved_zone or args.zone
|
||||
server_label = result.server_name or "unknown-server"
|
||||
print(f"OK {host:<45} -> {addresses} via {server_label} [{zone_label}]")
|
||||
else:
|
||||
print(f"FAIL {host:<45} ({result.message})")
|
||||
failed.append((host, hosts[host], result))
|
||||
|
||||
if failed:
|
||||
print()
|
||||
print(f"ERROR: {len(failed)} host(s) failed FlowerCore.DNS preflight.")
|
||||
for host, refs, result in failed:
|
||||
print(f" {host}")
|
||||
print(f" preflight: {result.message}")
|
||||
print(f" challenge: {result.challenge_fqdn}")
|
||||
for ref in sorted(set(refs)):
|
||||
print(f" via: {ref}")
|
||||
print()
|
||||
print("Fix the DNS record in FlowerCore.DNS before merging, then rerun this gate.")
|
||||
print()
|
||||
print("Example:")
|
||||
print(f" curl -sk {args.base_url.rstrip('/')}/api/v1/servers")
|
||||
print(
|
||||
" curl -sk -X POST "
|
||||
f"{args.base_url.rstrip('/')}/api/v1/servers/<serverId>/zones/{args.zone}/records "
|
||||
"-H 'Content-Type: application/json' "
|
||||
"-d '{\"name\":\"<host>\",\"type\":\"A\",\"data\":\"10.0.56.200\",\"ttl\":300}'"
|
||||
)
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(f"All {len(hosts)} iamworkin.lan host(s) passed FlowerCore.DNS preflight. Safe to deploy.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user