Compare commits
245 Commits
6c1375b21a
...
codex/s54-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e93d482c | ||
| 4319cc2b51 | |||
|
|
2bf339ce51 | ||
|
|
5bdedfc5ae | ||
|
|
0307ae16ae | ||
|
|
6c18f69cf2 | ||
|
|
47e2256556 | ||
|
|
9d77f8ba0e | ||
|
|
2f4be19c85 | ||
|
|
2a62c40990 | ||
|
|
7be98e5efc | ||
|
|
a65b356c9d | ||
|
|
08c17ef1b4 | ||
|
|
06f2f002b7 | ||
|
|
7ac4a8b4b7 | ||
|
|
90f2a86819 | ||
|
|
cbdefb2b23 | ||
|
|
1c36fe3a0a | ||
|
|
2b420ce8a4 | ||
|
|
5cbc1a06b1 | ||
|
|
9e7ee39b3a | ||
|
|
ae030a5f33 | ||
| bc8c35896f | |||
|
|
2cc91b6df0 | ||
| 0d2090fe81 | |||
|
|
bc3548e715 | ||
| 74333cc26b | |||
|
|
7310fb88c2 | ||
| 148bc87b9a | |||
|
|
2a1e842100 | ||
| bc28430d24 | |||
|
|
cc92272217 | ||
| d6f4468a9c | |||
|
|
2f796a2ebd | ||
| 1f1f6823db | |||
|
|
b92f74b63a | ||
|
|
cb7f7dbc4d | ||
|
|
03126d5584 | ||
|
|
495e884c41 | ||
|
|
65aa1e6104 | ||
|
|
7f2a3b76b4 | ||
| ea73f00461 | |||
|
|
25ace30a03 | ||
|
|
ca574c2280 | ||
|
|
09387f90e1 | ||
|
|
e641ceab48 | ||
|
|
c263426ea5 | ||
|
|
bacac067cf | ||
| 914fed08d8 | |||
|
|
200aeab032 | ||
|
|
8182616d4c | ||
|
|
f0862ac03c | ||
|
|
46c392605e | ||
| 89b147bbdd | |||
| d7238a5e3b | |||
| fc444a02a1 | |||
| 83d4883d55 | |||
| f8fe3b2688 | |||
| f2ab892ebc | |||
| fef68a9560 | |||
|
|
6fe77225ae | ||
| 634b9c4169 | |||
| b8c7e59005 | |||
| 65ac8d6f01 | |||
| 35844e0dbd | |||
| b1e307151e | |||
| 12b07219c7 | |||
| 9fd32c4415 | |||
| ad670fb344 | |||
|
|
6f6ca50987 | ||
|
|
c7be58c1f7 | ||
|
|
a1f5a393cd | ||
|
|
710340d8be | ||
|
|
7d2daaa4f8 | ||
|
|
e50e103ba0 | ||
|
|
e8094eb0bd | ||
| 8d87d9172c | |||
|
|
cfd9743afa | ||
|
|
5029e209cd | ||
|
|
f298339152 | ||
|
|
6e7d88db49 | ||
|
|
5ae50bd491 | ||
|
|
653d4472f5 | ||
|
|
eb8693e1ce | ||
|
|
667777a653 | ||
|
|
84c9feb893 | ||
|
|
427dbfcef2 | ||
|
|
b651a4e2d0 | ||
|
|
b998f50f48 | ||
|
|
8fd9ae1cd3 | ||
|
|
fc2aca0e9e | ||
|
|
ba18c52130 | ||
|
|
9f6dc1a9d5 | ||
|
|
0bf47dfa33 | ||
|
|
87a7d7c70a | ||
|
|
1c4145a581 | ||
|
|
c50a403f74 | ||
|
|
fb7bd10528 | ||
|
|
6c21d14a98 | ||
|
|
b3529f8e96 | ||
|
|
00c11b4eaa | ||
|
|
04881f46f0 | ||
|
|
c0038e4859 | ||
|
|
dee48831c6 | ||
|
|
0f1dc5f871 | ||
|
|
11c5f6e6cc | ||
|
|
d637fe9b30 | ||
|
|
5bfe41beca | ||
|
|
df22774674 | ||
|
|
c4065b15a3 | ||
|
|
a4aa612373 | ||
|
|
c2eb37dee9 | ||
|
|
bf6f542569 | ||
|
|
e150b2102f | ||
|
|
33a765b0bc | ||
|
|
5484ed7db6 | ||
|
|
2aa84349ea | ||
|
|
851f8e673b | ||
|
|
f78f8c8192 | ||
|
|
9b255fefc1 | ||
|
|
6a89a76e39 | ||
|
|
2489464d4f | ||
|
|
4b777b16ac | ||
|
|
8c60e3a4d3 | ||
|
|
df02b4c3c3 | ||
|
|
c0dceafffd | ||
|
|
490db8f9e6 | ||
|
|
1926bdaf3b | ||
|
|
ca8d062826 | ||
|
|
1889462fc4 | ||
|
|
523ba61232 | ||
|
|
53f67c8713 | ||
|
|
6b9cf3d12c | ||
|
|
0b52093b36 | ||
|
|
7a9098d3bd | ||
|
|
57d7ba46a7 | ||
|
|
9ec2e2d52e | ||
|
|
b4d62a8a50 | ||
|
|
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 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/.gitattributes text eol=lf
|
||||
*.sh text eol=lf
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# .NET build outputs (lint test project)
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Editor / temp
|
||||
.DS_Store
|
||||
*.swp
|
||||
26
README.md
26
README.md
@@ -99,10 +99,36 @@ curl -sk -X DELETE https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iam
|
||||
- **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`.
|
||||
- **IngressRoute namespace split**: this RKE2 Traefik install does not allow cross-namespace service refs. Keep the `IngressRoute`, backend `Service`, and TLS secret in the same namespace; if one host is shared across namespaces, duplicate the `Certificate` and move the route next to the destination service.
|
||||
- **Public read-only hosts**: if a public host fronts a service that also exposes admin writes internally, add a Traefik route match like `Host(...) && (Method(GET) || Method(HEAD))` on the public edge instead of trusting the app to reject unsafe methods.
|
||||
- **Public read-write allowlist hosts**: if a public host accepts a tightly bounded write surface (e.g. bootstrap-JWT POST), pin the allowlist as `(Method(GET) || Method(HEAD) || Method(POST) || Method(OPTIONS))`. PUT/PATCH/DELETE must still 404 at the route. Track A's `updatecenter.iamworkin.lan` / `updates.iamworkin.lan` are the canonical example. The lint test enforces this invariant.
|
||||
- **Traefik VIP netpols**: when a `NetworkPolicy` allows `10.0.56.200`, also allow the post-DNAT backend ports (`8443` for TLS plus `8080` or `8000` for HTTP) or Calico will drop the rewritten flow.
|
||||
- **Auth-safe probes**: services behind API-key or global auth middleware should prefer `tcpSocket` probes unless `/health` is explicitly exempted before the middleware runs.
|
||||
- **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.
|
||||
|
||||
## Local manifest lint
|
||||
|
||||
The repo now carries a local-first lint pass for the recurring K8s gotchas that have burned the fleet:
|
||||
|
||||
```bash
|
||||
dotnet test tests/bluejay-infra-lint/BluejayInfraLint.Tests.csproj -c Release
|
||||
```
|
||||
|
||||
That test project sweeps `bluejay-infra/apps/**` plus the canonical sibling `FlowerCore.*\\k8s` manifests that share the same workspace. Matching `conftest.dev` policy files live under `tests/bluejay-infra-lint/conftest.dev/` for environments that also have `conftest` or `opa`.
|
||||
|
||||
## Non-K8s Pi Artifacts
|
||||
|
||||
Some `apps/*` directories are deployment artifact bundles consumed by Puppet
|
||||
instead of Kubernetes workloads. `apps/fc-signage-pi-player/` carries the
|
||||
Chromium signage Pi player, `apps/fc-divoom-dm-pi-device/` carries the additive
|
||||
edge2 Divoom-as-DeviceManagement-device profile/Hiera contract, and
|
||||
`apps/fc-divoom-tv-pi/` carries the Divoom TV Pi HDMI systemd/Puppet shape.
|
||||
These bundles intentionally avoid Deployment, IngressRoute, Certificate, and
|
||||
OnePasswordItem resources.
|
||||
|
||||
## References
|
||||
|
||||
- OpenVox noc1 durability runbook: `docs/runbooks/openvoxserver-quadlet-durability.md`
|
||||
- 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`
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
# Agent Zero AI Stack — NUC Deployment (RKE2 Bare-Metal)
|
||||
# =============================================================================
|
||||
# Deploys: AgentZero (agent UI) on RKE2 cluster with Blue Jay profile
|
||||
# Ollama: workstation-first via BLUEJAY-WS (10.0.56.20:11434) with edge1 Pi 5
|
||||
# fallback (10.0.57.17:11434)
|
||||
# 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)
|
||||
# - Prefers workstation Ollama on the R9700, falls back to edge1 Pi 5
|
||||
# - 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 — profile/tools/extensions loaded via ConfigMaps
|
||||
@@ -91,14 +92,17 @@ subjects:
|
||||
# =============================================================================
|
||||
# Agent Zero — AI Agent Web UI (NUC Edition, Blue Jay Profile)
|
||||
# =============================================================================
|
||||
# Connects to a local proxy that routes to workstation Ollama first and edge1 second
|
||||
# Blue Jay profile with 21 tools, 3 prompts, 4 extensions
|
||||
# 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_model routing).
|
||||
# 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 the chat_model only; util / embedding / browser stay on local
|
||||
# Ollama via the 127.0.0.1 sidecar proxy.
|
||||
# Consumed by chat, internal util/embed, browser, and corpus-search requests
|
||||
# that traverse fc-llm-bridge.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
@@ -107,6 +111,34 @@ metadata:
|
||||
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
|
||||
kind: Deployment
|
||||
@@ -118,7 +150,7 @@ metadata:
|
||||
annotations:
|
||||
agent-zero/deployment: "nuc"
|
||||
agent-zero/profile: "bluejay"
|
||||
agent-zero/ollama: "BLUEJAY-WS primary (10.0.56.20:11434), edge1 fallback (10.0.57.17:11434)"
|
||||
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:
|
||||
@@ -133,19 +165,18 @@ spec:
|
||||
spec:
|
||||
serviceAccountName: agent-zero
|
||||
initContainers:
|
||||
# Wait for either workstation or edge1 Ollama to be reachable before starting Agent Zero.
|
||||
- name: wait-for-ollama
|
||||
# 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 Ollama at BLUEJAY-WS or edge1..."
|
||||
until wget -qO- --timeout=2 http://10.0.56.20:11434/api/tags >/dev/null 2>&1 || \
|
||||
wget -qO- --timeout=2 http://10.0.57.17:11434/api/tags >/dev/null 2>&1; do
|
||||
echo "No Ollama endpoint ready yet, retrying in 5s..."
|
||||
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 "At least one Ollama endpoint is reachable."
|
||||
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
|
||||
@@ -192,50 +223,6 @@ spec:
|
||||
- name: bluejay-theme
|
||||
mountPath: /tmp/bluejay-theme
|
||||
containers:
|
||||
- name: ollama-proxy
|
||||
image: nginx:1.27-alpine
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
cat > /etc/nginx/nginx.conf <<'NGINX'
|
||||
worker_processes 1;
|
||||
events { worker_connections 1024; }
|
||||
http {
|
||||
upstream ollama_upstream {
|
||||
server 10.0.56.20:11434 max_fails=2 fail_timeout=10s;
|
||||
server 10.0.57.17:11434 backup;
|
||||
keepalive 16;
|
||||
}
|
||||
server {
|
||||
listen 11434;
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
|
||||
proxy_pass http://ollama_upstream;
|
||||
}
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
exec nginx -g 'daemon off;'
|
||||
ports:
|
||||
- containerPort: 11434
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/tags
|
||||
port: 11434
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/tags
|
||||
port: 11434
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
- name: agent-zero
|
||||
image: agent0ai/agent-zero:latest
|
||||
command: ["/bin/bash", "-c"]
|
||||
@@ -256,23 +243,41 @@ spec:
|
||||
# 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). util + embedding stay on local 127.0.0.1 Ollama
|
||||
# proxy (workstation primary, edge1 fallback).
|
||||
# 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":"ollama","name":"qwen2.5:1.5b","api_base":"http://127.0.0.1:11434","ctx_length":8192,"ctx_input":0.7,"kwargs":{"num_ctx":8192}},"embedding_model":{"provider":"ollama","name":"nomic-embed-text","api_base":"http://127.0.0.1:11434","kwargs":{}}}
|
||||
{"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 CHAT_MCP_API_KEY env var before initialize.sh.
|
||||
# Use the in-cluster Chat service URL rather than the public
|
||||
# Traefik hostname so the pod stays off the private VIP lane that
|
||||
# the default egress rule blocks.
|
||||
if [ -n "${CHAT_MCP_API_KEY:-}" ]; then
|
||||
export A0_SET_mcp_servers="{\"mcpServers\":{\"fc-chat\":{\"type\":\"streamable-http\",\"url\":\"http://chat-web.fc-chat.svc/mcp\",\"headers\":{\"X-Api-Key\":\"${CHAT_MCP_API_KEY}\"}}}}"
|
||||
# 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:
|
||||
@@ -284,8 +289,9 @@ spec:
|
||||
# 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.
|
||||
# Util / embedding / browser stay on local Ollama via 127.0.0.1 proxy
|
||||
# for zero-latency, zero-cost small-model traffic.
|
||||
# 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: "openai"
|
||||
- name: A0_SET_chat_model_name
|
||||
@@ -307,35 +313,51 @@ spec:
|
||||
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: "8192"
|
||||
- name: A0_SET_chat_model_kwargs
|
||||
value: '{"temperature": 0, "num_ctx": 8192}'
|
||||
# Utility model — fast small helper tier through the same proxy
|
||||
# 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:1.5b"
|
||||
value: "fc:cheap"
|
||||
- name: A0_SET_util_model_api_base
|
||||
value: "http://127.0.0.1:11434"
|
||||
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
|
||||
- name: A0_SET_util_model_kwargs
|
||||
value: '{"num_ctx": 2048}'
|
||||
# Embedding model — nomic through the same proxy
|
||||
# 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://127.0.0.1:11434"
|
||||
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: "gemma3:4b"
|
||||
- name: A0_SET_browser_model_api_base
|
||||
value: "http://127.0.0.1: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: "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: "bluejay"
|
||||
@@ -358,9 +380,38 @@ spec:
|
||||
name: chat-mcp-api-key
|
||||
key: api-key
|
||||
optional: true
|
||||
# Print.Web — Thermal printer service on edge2
|
||||
# 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"
|
||||
@@ -395,7 +446,7 @@ spec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- "curl -sf http://localhost:80/ > /dev/null && curl -sf --connect-timeout 3 http://127.0.0.1:11434/api/tags > /dev/null"
|
||||
- "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:
|
||||
@@ -533,18 +584,6 @@ spec:
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# Ollama on BLUEJAY-WS
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.56.20/32
|
||||
ports:
|
||||
- port: 11434
|
||||
# Ollama on edge1 fallback
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.57.17/32
|
||||
ports:
|
||||
- port: 11434
|
||||
# Print.Web on edge2
|
||||
- to:
|
||||
- ipBlock:
|
||||
@@ -578,6 +617,26 @@ spec:
|
||||
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:
|
||||
|
||||
@@ -7209,6 +7209,9 @@ data:
|
||||
"keep_alive": keep_alive,
|
||||
"stream": False,
|
||||
})
|
||||
curl_headers = ["-H", "Content-Type: application/json"]
|
||||
if os.environ.get("FC_LLM_BRIDGE_API_KEY"):
|
||||
curl_headers.extend(["-H", f"X-Api-Key: {os.environ['FC_LLM_BRIDGE_API_KEY']}"])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -7216,7 +7219,7 @@ data:
|
||||
"curl", "-s", "--max-time", "120",
|
||||
"-X", "POST",
|
||||
f"{api_base}/api/generate",
|
||||
"-H", "Content-Type: application/json",
|
||||
*curl_headers,
|
||||
"-d", payload,
|
||||
],
|
||||
capture_output=True,
|
||||
@@ -13150,6 +13153,451 @@ data:
|
||||
- PowerShell 5.1 compatibility is assumed (no PowerShell 7+ features).
|
||||
- All commands run with `-NoProfile -NonInteractive` flags for clean execution.
|
||||
"""
|
||||
corpus_search.py: |
|
||||
# FlowerCore Fleet Corpus Vector Search Tool
|
||||
#
|
||||
# Queries the AiStation-built SqliteVecVectorStore DB at /a0/usr/vectors/fleet.db
|
||||
# (bind-mounted read-only from /var/lib/flowercore/vector-stores/ on the host).
|
||||
# Embeds the query through Ollama's nomic-embed-text model, computes cosine
|
||||
# similarity against every stored chunk in pure Python (no numpy — not present
|
||||
# in the container), and returns the top-K nearest neighbors with source metadata.
|
||||
#
|
||||
# This is the offline-friendly counterpart to `intranet_search` (which hits the
|
||||
# Intranet's live REST API). Use it for Bible/Greek/Hebrew/Strong's lookups and
|
||||
# anywhere the workstation has a newer DB than the Intranet one. The store is
|
||||
# refreshed by `aistation-indexer build <edition>` — see the FlowerCore.Knowledge
|
||||
# ADR at docs/ai-agents/flowercore-knowledge-service-plan.md.
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sqlite3
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from python.helpers.tool import Tool, Response
|
||||
|
||||
|
||||
DEFAULT_VECTORS_DIR = os.environ.get(
|
||||
"FLOWERCORE_FLEET_VECTOR_DIR",
|
||||
"/a0/usr/vectors",
|
||||
)
|
||||
# When the caller doesn't pick an explicit DB, prefer the biggest fleet tier
|
||||
# present on disk. Workstation → pi-edge → bmo-bot.
|
||||
PREFERRED_DB_ORDER = [
|
||||
os.environ.get("FLOWERCORE_FLEET_VECTOR_DB", ""),
|
||||
"fleet-workstation-full.db",
|
||||
"fleet-pi-edge.db",
|
||||
"fleet-bmo-bot.db",
|
||||
]
|
||||
OLLAMA_BASE_URL = os.environ.get(
|
||||
"FLOWERCORE_AGENTZERO_OLLAMA_URL",
|
||||
"http://host.containers.internal:11434",
|
||||
)
|
||||
BRIDGE_API_KEY = os.environ.get("FC_LLM_BRIDGE_API_KEY", "").strip()
|
||||
EMBEDDING_MODEL = os.environ.get(
|
||||
"FLOWERCORE_FLEET_EMBEDDING_MODEL",
|
||||
"nomic-embed-text",
|
||||
)
|
||||
|
||||
|
||||
class CorpusSearch(Tool):
|
||||
async def execute(self, **kwargs) -> Response:
|
||||
"""
|
||||
Semantic search over the FlowerCore fleet corpus (Bible texts, lexicons,
|
||||
dictionaries, morphology) pre-indexed by aistation-indexer.
|
||||
|
||||
Args (via self.args):
|
||||
query (str): Search query text. Required unless action=stats.
|
||||
limit (int): Max results. Default 8.
|
||||
index (str): Optional index name filter ("bible-texts", "lexicons",
|
||||
"dictionaries", "morphology"). Default: all indexes.
|
||||
repo (str): Optional repo filter (e.g. "world-english-bible").
|
||||
db (str): Override DB path OR file name inside FLOWERCORE_FLEET_VECTOR_DIR
|
||||
(defaults to /a0/usr/vectors). If omitted, the largest
|
||||
fleet tier present on disk is picked automatically.
|
||||
action (str): Optional. "stats" returns an inventory of all fleet DBs
|
||||
visible to the tool (names, sizes, index counts, chunk
|
||||
counts, last-built timestamps). No embedding call.
|
||||
|
||||
Returns:
|
||||
Response with ranked chunks (score, source, text preview) OR
|
||||
(when action=stats) a markdown inventory of available fleet DBs.
|
||||
"""
|
||||
query = (self.args.get("query") or "").strip()
|
||||
limit = int(self.args.get("limit") or 8)
|
||||
index_filter = (self.args.get("index") or "").strip()
|
||||
repo_filter = (self.args.get("repo") or "").strip()
|
||||
db_override = (self.args.get("db") or "").strip()
|
||||
action = (self.args.get("action") or "").strip().lower()
|
||||
|
||||
if action == "stats":
|
||||
return Response(message=_render_stats(), break_loop=False)
|
||||
|
||||
if not query:
|
||||
return Response(
|
||||
message=(
|
||||
"Error: 'query' is required unless action=stats.\n"
|
||||
"Example: query=\"what does Genesis 1:1 say\" limit=5\n"
|
||||
"Inventory: action=stats"
|
||||
),
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
db = _resolve_db(db_override)
|
||||
if db is None:
|
||||
return Response(
|
||||
message=(
|
||||
f"Error: no fleet vector DB found under {DEFAULT_VECTORS_DIR}.\n"
|
||||
"Host side: run `aistation-indexer build fleet-workstation-full`\n"
|
||||
"(or `fleet-pi-edge`/`fleet-bmo-bot`) to produce\n"
|
||||
"`/var/lib/flowercore/vector-stores/<slug>.db`, then confirm the\n"
|
||||
"Podman unit mounts that directory into `/a0/usr/vectors:ro`."
|
||||
),
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
try:
|
||||
query_vec = _embed(query)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
message=f"Error: failed to embed query via Ollama at {OLLAMA_BASE_URL}: {e}",
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
try:
|
||||
hits = _search(db, query_vec, index_filter, repo_filter, limit)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
message=f"Error: corpus search failed: {e}",
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
if not hits:
|
||||
return Response(
|
||||
message=(
|
||||
f"No matches for '{query}' in {db.name}.\n"
|
||||
f"Indexes available: " + _list_indexes_summary(db)
|
||||
),
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
lines = [f"**Corpus search: `{query}`** (top {len(hits)} of {limit} requested, DB={db.name})", ""]
|
||||
for rank, h in enumerate(hits, 1):
|
||||
passage = h.get("passage") or ""
|
||||
lang = h.get("language") or ""
|
||||
meta_bits = [x for x in (h["index"], h["repo"], passage, lang) if x]
|
||||
meta = " · ".join(meta_bits)
|
||||
preview = h["text"]
|
||||
if len(preview) > 320:
|
||||
preview = preview[:320].rstrip() + "…"
|
||||
lines.append(f"{rank}. **{h['score']:.3f}** {meta}")
|
||||
lines.append(f" `{h['source']}`")
|
||||
lines.append(f" {preview}")
|
||||
lines.append("")
|
||||
|
||||
return Response(message="\n".join(lines).rstrip() + "\n", break_loop=False)
|
||||
|
||||
|
||||
def _resolve_db(override: str) -> "Path | None":
|
||||
"""Pick a fleet DB by explicit path, explicit filename, or preferred order."""
|
||||
vectors_dir = Path(DEFAULT_VECTORS_DIR)
|
||||
if override:
|
||||
# Absolute or relative path that points at a real file wins outright.
|
||||
p = Path(override)
|
||||
if p.is_absolute() and p.exists():
|
||||
return p
|
||||
# Otherwise treat it as a filename within the vectors dir.
|
||||
candidate = vectors_dir / override
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
for name in PREFERRED_DB_ORDER:
|
||||
if not name:
|
||||
continue
|
||||
p = Path(name) if Path(name).is_absolute() else vectors_dir / name
|
||||
if p.exists():
|
||||
return p
|
||||
|
||||
# Fallback: any *.db in the dir, largest first.
|
||||
if vectors_dir.is_dir():
|
||||
candidates = sorted(vectors_dir.glob("*.db"), key=lambda p: p.stat().st_size, reverse=True)
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
|
||||
def _embed(text: str) -> list:
|
||||
"""Embed a query via Ollama's /api/embeddings. Single-vector response."""
|
||||
body = json.dumps({"model": EMBEDDING_MODEL, "prompt": text}).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if BRIDGE_API_KEY:
|
||||
headers["X-Api-Key"] = BRIDGE_API_KEY
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE_URL.rstrip('/')}/api/embeddings",
|
||||
data=body,
|
||||
headers=headers,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
vec = data.get("embedding")
|
||||
if not isinstance(vec, list) or not vec:
|
||||
raise RuntimeError(f"Ollama returned no embedding: {data}")
|
||||
return [float(x) for x in vec]
|
||||
|
||||
|
||||
def _cosine(a: list, b: list) -> float:
|
||||
"""Cosine similarity in pure Python — no numpy in the A0 container."""
|
||||
# zip() stops at the shorter — AiStation DB guarantees same dim per index.
|
||||
dot = 0.0
|
||||
na = 0.0
|
||||
nb = 0.0
|
||||
for x, y in zip(a, b):
|
||||
dot += x * y
|
||||
na += x * x
|
||||
nb += y * y
|
||||
if na == 0.0 or nb == 0.0:
|
||||
return 0.0
|
||||
return dot / (math.sqrt(na) * math.sqrt(nb))
|
||||
|
||||
|
||||
def _search(db_path: Path, query_vec: list, index_filter: str, repo_filter: str, limit: int) -> list:
|
||||
"""Load entries, compute cosine, return top-K.
|
||||
|
||||
SqliteVecVectorStore schema:
|
||||
VectorIndexes(IndexName, Dimensions, UpdatedAtUtc)
|
||||
VectorEntries(IndexName, ChunkId, TextContent, SourceRepo, SourceFile,
|
||||
Book, Chapter, VerseRange, Language, ContentType, License,
|
||||
EstimatedTokens, EmbeddingJson)
|
||||
|
||||
Embeddings are stored as JSON arrays in EmbeddingJson; similarity is computed
|
||||
in Python. For ~100k chunks × 768 dims this takes a couple seconds on a
|
||||
workstation — acceptable for interactive A0 use.
|
||||
"""
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
sql = [
|
||||
"SELECT IndexName, ChunkId, TextContent, SourceRepo, SourceFile, ",
|
||||
" Book, Chapter, VerseRange, Language, EmbeddingJson ",
|
||||
"FROM VectorEntries",
|
||||
]
|
||||
where = []
|
||||
params = []
|
||||
if index_filter:
|
||||
where.append("IndexName = ?")
|
||||
params.append(index_filter)
|
||||
if repo_filter:
|
||||
where.append("SourceRepo LIKE ?")
|
||||
params.append(f"%{repo_filter}%")
|
||||
if where:
|
||||
sql.append(" WHERE " + " AND ".join(where))
|
||||
sql.append(";")
|
||||
|
||||
cursor = conn.execute("".join(sql), params)
|
||||
|
||||
# Min-heap by (score, ...) would be faster but for interactive use we
|
||||
# just sort at the end — simpler and readable.
|
||||
scored = []
|
||||
for row in cursor:
|
||||
idx, chunk_id, text, repo, source_file, book, chapter, verses, lang, emb_json = row
|
||||
try:
|
||||
vec = json.loads(emb_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
score = _cosine(query_vec, vec)
|
||||
passage = None
|
||||
if book and chapter:
|
||||
passage = f"{book} {chapter}"
|
||||
if verses:
|
||||
passage += f":{verses}"
|
||||
scored.append((score, {
|
||||
"index": idx,
|
||||
"chunk_id": chunk_id,
|
||||
"text": text,
|
||||
"repo": repo or "",
|
||||
"source": source_file or "",
|
||||
"passage": passage or "",
|
||||
"language": lang or "",
|
||||
}))
|
||||
scored.sort(key=lambda t: t[0], reverse=True)
|
||||
return [{"score": s, **meta} for s, meta in scored[:limit]]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _render_stats() -> str:
|
||||
"""Markdown inventory of every *.db in FLOWERCORE_FLEET_VECTOR_DIR."""
|
||||
vectors_dir = Path(DEFAULT_VECTORS_DIR)
|
||||
if not vectors_dir.is_dir():
|
||||
return f"No fleet vector dir mounted at {vectors_dir}. Ask the host operator to build an index with scripts/agent-zero/build-fleet-index.sh."
|
||||
|
||||
dbs = sorted(vectors_dir.glob("*.db"))
|
||||
if not dbs:
|
||||
return f"No fleet DBs present under {vectors_dir}. Run `scripts/agent-zero/build-fleet-index.sh fleet-workstation-full` on the host."
|
||||
|
||||
lines = [f"**Fleet vector DB inventory** ({vectors_dir})", ""]
|
||||
for db in dbs:
|
||||
size_mb = db.stat().st_size / (1024 * 1024)
|
||||
lines.append(f"### `{db.name}` ({size_mb:.1f} MB)")
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
try:
|
||||
idx_rows = conn.execute(
|
||||
"SELECT IndexName, Dimensions, UpdatedAtUtc FROM VectorIndexes ORDER BY IndexName;"
|
||||
).fetchall()
|
||||
if not idx_rows:
|
||||
lines.append("- (no indexes registered)")
|
||||
else:
|
||||
counts = dict(conn.execute(
|
||||
"SELECT IndexName, COUNT(*) FROM VectorEntries GROUP BY IndexName;"
|
||||
).fetchall())
|
||||
for name, dim, updated in idx_rows:
|
||||
count = counts.get(name, 0)
|
||||
lines.append(f"- **{name}** — {count:,} chunks × {dim}d (built {updated})")
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
lines.append(f"- (inspect failed: {e})")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"**Tool defaults:** embedding model `{EMBEDDING_MODEL}`, Ollama at `{OLLAMA_BASE_URL}`. Pick a DB with `db=<filename>`; filter by `index=<name>`/`repo=<substring>`.")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _list_indexes_summary(db_path: Path) -> str:
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT IndexName, Dimensions, "
|
||||
" (SELECT COUNT(*) FROM VectorEntries WHERE VectorEntries.IndexName = VectorIndexes.IndexName) "
|
||||
"FROM VectorIndexes ORDER BY IndexName;"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "(no indexes)"
|
||||
return ", ".join(f"{r[0]}({r[2]}×{r[1]}d)" for r in rows)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
return f"(couldn't list: {e})"
|
||||
|
||||
intranet_search.py: |
|
||||
# Intranet Vector Search Tool
|
||||
# Queries the Blue Jay Lab Intranet's Shared.Indexing RAG corpus over its
|
||||
# live REST API (https://intranet.iamworkin.lan/search). Returns ranked chunks
|
||||
# with source file paths and scores.
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from python.helpers.tool import Tool, Response
|
||||
|
||||
|
||||
INTRANET_BASE_URL = os.environ.get(
|
||||
"FLOWERCORE_INTRANET_URL",
|
||||
"https://intranet.iamworkin.lan",
|
||||
)
|
||||
STEPCA_ROOT_CRT = "/a0/usr/ca/stepca-root.crt"
|
||||
|
||||
|
||||
def _ssl_ctx() -> ssl.SSLContext:
|
||||
ctx = ssl.create_default_context()
|
||||
if os.path.exists(STEPCA_ROOT_CRT):
|
||||
ctx.load_verify_locations(cafile=STEPCA_ROOT_CRT)
|
||||
return ctx
|
||||
|
||||
|
||||
class IntranetSearch(Tool):
|
||||
async def execute(self, **kwargs) -> Response:
|
||||
"""
|
||||
Search the Blue Jay Lab intranet corpus (docs, project notes, dashboards).
|
||||
|
||||
Args (via self.args):
|
||||
query (str): Search query. Required.
|
||||
limit (int): Max chunks to return. Default 8.
|
||||
corpus (str): Optional corpus filter (e.g. "notes", "docs").
|
||||
|
||||
Returns:
|
||||
Response with ranked chunk text, source path, and score.
|
||||
"""
|
||||
query = self.args.get("query", "").strip()
|
||||
limit = int(self.args.get("limit", 8))
|
||||
corpus = self.args.get("corpus", "").strip()
|
||||
|
||||
if not query:
|
||||
return Response(
|
||||
message="Error: 'query' is required.",
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
params = {"q": query, "topK": str(limit)}
|
||||
if corpus:
|
||||
params["indexName"] = corpus
|
||||
url = f"{INTRANET_BASE_URL}/api/search?{urllib.parse.urlencode(params)}"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=20, context=_ssl_ctx()) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except Exception as exc:
|
||||
return Response(
|
||||
message=f"Intranet search failed: {exc}\nURL: {url}",
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return Response(
|
||||
message=f"Intranet returned non-JSON response:\n{raw[:500]}",
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
hits = data if isinstance(data, list) else (
|
||||
data.get("results") or data.get("hits") or data.get("chunks") or []
|
||||
)
|
||||
if not hits:
|
||||
return Response(
|
||||
message=f"No intranet results for query: {query!r}",
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
lines = [f"# Intranet search: {query} ({len(hits)} hits)\n"]
|
||||
for i, hit in enumerate(hits[:limit], 1):
|
||||
src = (
|
||||
hit.get("sourceFile")
|
||||
or hit.get("source")
|
||||
or hit.get("path")
|
||||
or hit.get("file")
|
||||
or "?"
|
||||
)
|
||||
repo = hit.get("sourceRepo") or ""
|
||||
idx = hit.get("indexName") or ""
|
||||
score = hit.get("score") or hit.get("similarity") or ""
|
||||
text = (
|
||||
hit.get("snippet")
|
||||
or hit.get("text")
|
||||
or hit.get("content")
|
||||
or hit.get("chunk")
|
||||
or ""
|
||||
).strip()
|
||||
if len(text) > 600:
|
||||
text = text[:600] + "..."
|
||||
header = f"## [{i}] {repo}/{src}" if repo else f"## [{i}] {src}"
|
||||
if idx:
|
||||
header += f" ({idx})"
|
||||
if score:
|
||||
header += f" score={score:.3f}" if isinstance(score, float) else f" score={score}"
|
||||
lines.append(header)
|
||||
lines.append(text)
|
||||
lines.append("")
|
||||
|
||||
return Response(message="\n".join(lines), break_loop=False)
|
||||
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: bluejay-tools-c
|
||||
|
||||
@@ -16,13 +16,25 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app: asterisk
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: rke2-agent1
|
||||
hostNetwork: true
|
||||
dnsPolicy: ClusterFirstWithHostNet
|
||||
securityContext:
|
||||
fsGroup: 0
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: rke2-agent1
|
||||
hostNetwork: true
|
||||
# Keep the search list free of iamworkin.lan so CoreDNS's wildcard
|
||||
# template cannot hijack public egress like downloads.asterisk.org.
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- 10.43.0.10
|
||||
searches:
|
||||
- telephony.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- name: ndots
|
||||
value: "2"
|
||||
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
|
||||
|
||||
448
apps/authentik/authentik.yaml
Normal file
448
apps/authentik/authentik.yaml
Normal file
@@ -0,0 +1,448 @@
|
||||
# Authentik OIDC backend
|
||||
# ArgoCD-managed. BlueJay Lab.
|
||||
#
|
||||
# Stack:
|
||||
# - PostgreSQL 16 StatefulSet (single replica, Longhorn RWO 5Gi)
|
||||
# - Redis 7 Deployment (no persistence — session/cache only)
|
||||
# - Authentik server + worker Deployments (image ghcr.io/goauthentik/server:2024.12.3)
|
||||
# - Media PVC shared between server + worker (Longhorn RWO 2Gi)
|
||||
# - Certificate via step-ca-acme ClusterIssuer
|
||||
# - Traefik IngressRoute at id.iamworkin.lan
|
||||
#
|
||||
# Secrets come from 1Password item "authentik-credentials" (IAmWorkin vault, id y6i74ch22q5wvm7znquq4nhhcu)
|
||||
# via the OnePasswordItem CRD, materialized into k8s Secret authentik/authentik-credentials.
|
||||
#
|
||||
# Why the discovery URL is /application/o/pimanager/ : Authentik issues per-application OIDC providers.
|
||||
# The pimanager OIDC application/provider is created after the cluster pods are healthy (manual or
|
||||
# via API once the bootstrap token is available — see Notes substrate).
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: authentik
|
||||
labels:
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
|
||||
---
|
||||
# 1Password operator pulls the authentik-credentials item into a k8s Secret of the same name.
|
||||
# Field labels in 1P become Secret keys: AUTHENTIK_SECRET_KEY, POSTGRES_PASSWORD, REDIS_PASSWORD,
|
||||
# BOOTSTRAP_ADMIN_PASSWORD, BOOTSTRAP_ADMIN_TOKEN, BOOTSTRAP_ADMIN_EMAIL.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: authentik-credentials
|
||||
namespace: authentik
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/authentik-credentials"
|
||||
|
||||
---
|
||||
# Shared media volume for server + worker pods.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: authentik-media
|
||||
namespace: authentik
|
||||
spec:
|
||||
storageClassName: longhorn
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
|
||||
---
|
||||
# PostgreSQL 16 StatefulSet — Authentik's primary store.
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: authentik-postgres
|
||||
namespace: authentik
|
||||
labels:
|
||||
app: authentik-postgres
|
||||
argocd.argoproj.io/instance: infra-authentik
|
||||
spec:
|
||||
persistentVolumeClaimRetentionPolicy:
|
||||
whenDeleted: Retain
|
||||
whenScaled: Retain
|
||||
podManagementPolicy: OrderedReady
|
||||
serviceName: authentik-postgres
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 10
|
||||
selector:
|
||||
matchLabels:
|
||||
app: authentik-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: authentik-postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: postgres
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: authentik
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: POSTGRES_DB
|
||||
value: authentik
|
||||
- name: POSTGRES_INITDB_ARGS
|
||||
value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "authentik"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "authentik"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests: { cpu: 100m, memory: 256Mi }
|
||||
limits: { cpu: 1000m, memory: 1Gi }
|
||||
volumeMounts:
|
||||
- name: pgdata
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: pgdata
|
||||
spec:
|
||||
storageClassName: longhorn
|
||||
accessModes: [ReadWriteOnce]
|
||||
volumeMode: Filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: authentik-postgres
|
||||
namespace: authentik
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: authentik-postgres
|
||||
ports:
|
||||
- name: postgres
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
|
||||
---
|
||||
# Redis 7 — session storage + Celery broker. No persistence needed (cache).
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: authentik-redis
|
||||
namespace: authentik
|
||||
labels:
|
||||
app: authentik-redis
|
||||
argocd.argoproj.io/instance: infra-authentik
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: authentik-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: authentik-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
args:
|
||||
- "--save"
|
||||
- ""
|
||||
- "--appendonly"
|
||||
- "no"
|
||||
- "--requirepass"
|
||||
- "$(REDIS_PASSWORD)"
|
||||
env:
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: REDIS_PASSWORD
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: redis
|
||||
readinessProbe:
|
||||
tcpSocket: { port: 6379 }
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
tcpSocket: { port: 6379 }
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests: { cpu: 50m, memory: 64Mi }
|
||||
limits: { cpu: 500m, memory: 256Mi }
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: authentik-redis
|
||||
namespace: authentik
|
||||
spec:
|
||||
selector:
|
||||
app: authentik-redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
|
||||
---
|
||||
# Authentik server Deployment — HTTP frontend on :9000.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: authentik-server
|
||||
namespace: authentik
|
||||
labels:
|
||||
app: authentik-server
|
||||
argocd.argoproj.io/instance: infra-authentik
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate # shares /media RWO PVC with worker
|
||||
selector:
|
||||
matchLabels:
|
||||
app: authentik-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: authentik-server
|
||||
spec:
|
||||
securityContext:
|
||||
# Authentik image runs as uid 1000 "authentik" but the Longhorn PVC mounts
|
||||
# root:root by default. fsGroup recursively chgrp + chmod g+rwx so the
|
||||
# non-root container can mkdir /media/public during the tenant_files migration.
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: server
|
||||
image: ghcr.io/goauthentik/server:2024.12.3
|
||||
args: ["server"]
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: http
|
||||
- containerPort: 9443
|
||||
name: https
|
||||
env:
|
||||
- name: AUTHENTIK_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: AUTHENTIK_SECRET_KEY
|
||||
- name: AUTHENTIK_REDIS__HOST
|
||||
value: authentik-redis
|
||||
- name: AUTHENTIK_REDIS__PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: REDIS_PASSWORD
|
||||
- name: AUTHENTIK_POSTGRESQL__HOST
|
||||
value: authentik-postgres
|
||||
- name: AUTHENTIK_POSTGRESQL__NAME
|
||||
value: authentik
|
||||
- name: AUTHENTIK_POSTGRESQL__USER
|
||||
value: authentik
|
||||
- name: AUTHENTIK_POSTGRESQL__PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: AUTHENTIK_BOOTSTRAP_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: BOOTSTRAP_ADMIN_PASSWORD
|
||||
- name: AUTHENTIK_BOOTSTRAP_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: BOOTSTRAP_ADMIN_TOKEN
|
||||
- name: AUTHENTIK_BOOTSTRAP_EMAIL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: BOOTSTRAP_ADMIN_EMAIL
|
||||
- name: AUTHENTIK_DISABLE_UPDATE_CHECK
|
||||
value: "true"
|
||||
- name: AUTHENTIK_ERROR_REPORTING__ENABLED
|
||||
value: "false"
|
||||
- name: AUTHENTIK_LOG_LEVEL
|
||||
value: info
|
||||
# First-boot Authentik can take 3+ min on the migration phase
|
||||
# (waiting on DB lock while worker also runs migrations). Initial
|
||||
# delays are generous so kubelet doesn't kill the pod mid-migration;
|
||||
# periodSeconds keeps post-startup probing responsive.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /-/health/ready/
|
||||
port: 9000
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 12
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /-/health/live/
|
||||
port: 9000
|
||||
initialDelaySeconds: 300
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /-/health/live/
|
||||
port: 9000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 40 # 30s + 40*15s = 10.5 min budget
|
||||
resources:
|
||||
requests: { cpu: 150m, memory: 512Mi }
|
||||
limits: { cpu: 1500m, memory: 1Gi }
|
||||
volumeMounts:
|
||||
- name: media
|
||||
mountPath: /media
|
||||
volumes:
|
||||
- name: media
|
||||
persistentVolumeClaim:
|
||||
claimName: authentik-media
|
||||
|
||||
---
|
||||
# Authentik worker Deployment — runs Celery background tasks.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: authentik-worker
|
||||
namespace: authentik
|
||||
labels:
|
||||
app: authentik-worker
|
||||
argocd.argoproj.io/instance: infra-authentik
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate # shares /media RWO PVC with server
|
||||
selector:
|
||||
matchLabels:
|
||||
app: authentik-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: authentik-worker
|
||||
spec:
|
||||
securityContext:
|
||||
# Same as server pod — non-root uid 1000 needs PVC group write.
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: worker
|
||||
image: ghcr.io/goauthentik/server:2024.12.3
|
||||
args: ["worker"]
|
||||
env:
|
||||
- name: AUTHENTIK_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: AUTHENTIK_SECRET_KEY
|
||||
- name: AUTHENTIK_REDIS__HOST
|
||||
value: authentik-redis
|
||||
- name: AUTHENTIK_REDIS__PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: REDIS_PASSWORD
|
||||
- name: AUTHENTIK_POSTGRESQL__HOST
|
||||
value: authentik-postgres
|
||||
- name: AUTHENTIK_POSTGRESQL__NAME
|
||||
value: authentik
|
||||
- name: AUTHENTIK_POSTGRESQL__USER
|
||||
value: authentik
|
||||
- name: AUTHENTIK_POSTGRESQL__PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: authentik-credentials
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: AUTHENTIK_DISABLE_UPDATE_CHECK
|
||||
value: "true"
|
||||
- name: AUTHENTIK_ERROR_REPORTING__ENABLED
|
||||
value: "false"
|
||||
- name: AUTHENTIK_LOG_LEVEL
|
||||
value: info
|
||||
resources:
|
||||
requests: { cpu: 100m, memory: 256Mi }
|
||||
limits: { cpu: 1000m, memory: 768Mi }
|
||||
volumeMounts:
|
||||
- name: media
|
||||
mountPath: /media
|
||||
volumes:
|
||||
- name: media
|
||||
persistentVolumeClaim:
|
||||
claimName: authentik-media
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: authentik-server
|
||||
namespace: authentik
|
||||
spec:
|
||||
selector:
|
||||
app: authentik-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 9000
|
||||
targetPort: 9000
|
||||
- name: https
|
||||
port: 9443
|
||||
targetPort: 9443
|
||||
|
||||
---
|
||||
# step-ca leaf certificate for id.iamworkin.lan.
|
||||
# step-ca container resolver uses pfSense Unbound, so the public A record for id.iamworkin.lan
|
||||
# MUST exist before this Certificate is applied (cert-manager HTTP-01 will silently 2h-backoff
|
||||
# otherwise). Added 2026-05-25 via scripts/pfsense-add-id-host.py.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: authentik-tls
|
||||
namespace: authentik
|
||||
spec:
|
||||
secretName: authentik-tls
|
||||
dnsNames:
|
||||
- id.iamworkin.lan
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: authentik
|
||||
namespace: authentik
|
||||
spec:
|
||||
entryPoints: [websecure]
|
||||
routes:
|
||||
- match: Host(`id.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: authentik-server
|
||||
port: 9000
|
||||
tls:
|
||||
secretName: authentik-tls
|
||||
69
apps/cdi/README.md
Normal file
69
apps/cdi/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# CDI — Containerized Data Importer
|
||||
|
||||
KubeVirt's `containerized-data-importer` for populating PVCs from external
|
||||
sources (HTTP, HTTPS, container registry, S3, virtctl upload). Required to
|
||||
import the Windows Server 2025 ISO into the `windows-server-2025-iso` PVC
|
||||
that `apps/kubevirt-vms/ci1.yaml` mounts as a CDROM.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Source | Purpose |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `cdi-operator.yaml` | [`v1.65.0`](https://github.com/kubevirt/containerized-data-importer/releases/tag/v1.65.0) — verbatim copy | Installs operator + CRDs (5779 lines, large) |
|
||||
| `cdi-cr.yaml` | [`v1.65.0`](https://github.com/kubevirt/containerized-data-importer/releases/tag/v1.65.0) — annotated + commented | Tells operator to deploy CDI components |
|
||||
|
||||
`cdi-operator.yaml` is **vendored verbatim** from the upstream release for
|
||||
air-gap reproducibility (no internet fetch at deploy time, ArgoCD prune
|
||||
contracts hold). To bump versions:
|
||||
|
||||
```bash
|
||||
CDI_VER=v1.66.0 # for example
|
||||
curl -sL "https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VER}/cdi-operator.yaml" \
|
||||
-o apps/cdi/cdi-operator.yaml
|
||||
curl -sL "https://github.com/kubevirt/containerized-data-importer/releases/download/${CDI_VER}/cdi-cr.yaml" \
|
||||
-o /tmp/cdi-cr-new.yaml # then re-apply project header diff
|
||||
git diff apps/cdi/ # review
|
||||
git commit + push
|
||||
```
|
||||
|
||||
## Verify after deploy
|
||||
|
||||
```bash
|
||||
kubectl -n cdi get pods # operator + apiserver + deployment + uploadproxy
|
||||
kubectl get cdis cdi -o jsonpath='{.status.phase}' # "Deployed"
|
||||
kubectl get crd | grep cdi.kubevirt.io
|
||||
# Expected CRDs: datavolumes.cdi.kubevirt.io, cdiconfigs.cdi.kubevirt.io,
|
||||
# storageprofiles.cdi.kubevirt.io, dataimportcrons.cdi.kubevirt.io,
|
||||
# datasources.cdi.kubevirt.io, objecttransfers.cdi.kubevirt.io
|
||||
```
|
||||
|
||||
## Use after install
|
||||
|
||||
```yaml
|
||||
# Example DataVolume that imports from HTTP
|
||||
apiVersion: cdi.kubevirt.io/v1beta1
|
||||
kind: DataVolume
|
||||
metadata:
|
||||
name: my-iso
|
||||
spec:
|
||||
source:
|
||||
http:
|
||||
url: "https://server/path/to.iso"
|
||||
pvc:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
storageClassName: longhorn
|
||||
```
|
||||
|
||||
```bash
|
||||
# Or upload from local disk via virtctl
|
||||
virtctl image-upload pvc my-iso \
|
||||
--image-path ./my.iso \
|
||||
--size 10Gi \
|
||||
--storage-class longhorn \
|
||||
--access-mode ReadWriteOnce \
|
||||
--uploadproxy-url https://cdi-uploadproxy.cdi.svc:443 \
|
||||
--insecure
|
||||
```
|
||||
36
apps/cdi/cdi-cr.yaml
Normal file
36
apps/cdi/cdi-cr.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
# =============================================================================
|
||||
# CDI CR — Tells the CDI operator to install CDI components into the cluster.
|
||||
# =============================================================================
|
||||
# After cdi-operator.yaml is applied, the operator watches for THIS resource
|
||||
# (CDI named "cdi"). When found, it deploys cdi-apiserver, cdi-deployment,
|
||||
# cdi-uploadproxy, cdi-cronjob, and the importer/uploadserver/cloner pods.
|
||||
#
|
||||
# Configuration:
|
||||
# - HonorWaitForFirstConsumer: PVCs created by DataVolumes wait for first
|
||||
# pod to schedule before binding (lets storage class pick best node).
|
||||
# - WebhookPvcRendering: validates PVC creation against CDI policies.
|
||||
# - imagePullPolicy IfNotPresent: re-pull only on tag rotation.
|
||||
# - nodeSelector linux: pin to Linux nodes (no Windows worker support).
|
||||
#
|
||||
# Andrew may want to add a `uploadProxyURLOverride` later to expose the
|
||||
# uploadproxy via Traefik IngressRoute for `virtctl image-upload` from
|
||||
# BLUEJAY-WS without `kubectl port-forward`. Phase 2 enhancement.
|
||||
# =============================================================================
|
||||
apiVersion: cdi.kubevirt.io/v1beta1
|
||||
kind: CDI
|
||||
metadata:
|
||||
name: cdi
|
||||
annotations:
|
||||
bluejay.iamworkin.lan/source: "kubevirt/containerized-data-importer v1.65.0"
|
||||
spec:
|
||||
config:
|
||||
featureGates:
|
||||
- HonorWaitForFirstConsumer
|
||||
- WebhookPvcRendering
|
||||
imagePullPolicy: IfNotPresent
|
||||
infra:
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
workload:
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
5779
apps/cdi/cdi-operator.yaml
Normal file
5779
apps/cdi/cdi-operator.yaml
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||
@@ -30,3 +30,41 @@ spec:
|
||||
port: 80
|
||||
tls:
|
||||
secretName: chat-web-tls
|
||||
---
|
||||
# Public host profile marker. The app treats this header as authoritative for
|
||||
# the public twin, while the internal chat.iamworkin.lan route does not attach
|
||||
# it and keeps the operator-oriented UI.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: chat-public-profile-header
|
||||
namespace: fc-chat
|
||||
spec:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-FC-Chat-Host-Profile: "public"
|
||||
---
|
||||
# Public Cloudflare-fronted twin for the anonymous chat surface. Operator
|
||||
# paths are intentionally absent from the allowlist below, so /admin,
|
||||
# /operator, /console, /ops, /api/operator, and /operatorhub miss this route
|
||||
# and return Traefik 404 before reaching the pod. Operator action still needed:
|
||||
# create/verify Cloudflare DNS chat.flowercore.io -> public Traefik endpoint
|
||||
# and mirror the cf-origin-flowercore-io TLS secret into namespace fc-chat.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: chat-web-public
|
||||
namespace: fc-chat
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`chat.flowercore.io`) && (Path(`/`) || Path(`/chat`) || PathPrefix(`/_blazor`) || PathPrefix(`/_framework`) || PathPrefix(`/_content`) || PathPrefix(`/avatars`) || PathPrefix(`/css`) || PathPrefix(`/js`) || PathPrefix(`/favicon`) || PathPrefix(`/chathub`)) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: chat-public-profile-header
|
||||
services:
|
||||
- name: chat-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: cf-origin-flowercore-io
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# FlowerCore Remote Desktop — TLS + Ingress
|
||||
# Deployment and Service managed by deploy script (not ArgoCD)
|
||||
#
|
||||
# Source-of-truth split:
|
||||
# - bluejay-infra OWNS: Certificate, IngressRoute, all NetworkPolicies
|
||||
# (see network-policies.yaml in this directory).
|
||||
# - FlowerCore.RemoteDesktop scripts/deploy-web.sh OWNS: Deployment +
|
||||
# Service. Reason: image refs like `localhost/fc-desktop:linux-xfce`
|
||||
# only exist on each node's containerd after a manual import, so a
|
||||
# Deployment manifest in bluejay-infra would race the image-import
|
||||
# step and crash-loop.
|
||||
#
|
||||
# NetworkPolicies moved into bluejay-infra 2026-05-07 — previously they
|
||||
# were applied via the deploy script's kubectl apply calls, which broke
|
||||
# cluster-rebuild repeatability. See
|
||||
# feedback_networkpolicies_belong_in_bluejay_infra.md.
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
@@ -23,6 +36,14 @@ 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:
|
||||
|
||||
332
apps/fc-desktop/network-policies.yaml
Normal file
332
apps/fc-desktop/network-policies.yaml
Normal file
@@ -0,0 +1,332 @@
|
||||
# FlowerCore Remote Desktop — NetworkPolicies (GitOps-managed)
|
||||
#
|
||||
# Moved into bluejay-infra 2026-05-07 as part of the regroup audit. These
|
||||
# four policies were previously applied via FlowerCore.RemoteDesktop's
|
||||
# scripts/deploy-web.sh `kubectl apply` calls, which meant a fresh cluster
|
||||
# rebuild from bluejay-infra alone would miss them — Browser Lab session
|
||||
# isolation, control-plane allow-list, and HTTP-01 cert renewal would all
|
||||
# silently fail to come up.
|
||||
#
|
||||
# Source-of-truth contract:
|
||||
# - bluejay-infra OWNS all NetworkPolicy + Certificate + IngressRoute
|
||||
# resources for fc-desktop.
|
||||
# - FlowerCore.RemoteDesktop's scripts/deploy-web.sh continues to own
|
||||
# the Deployment + Service apply (because the image ref
|
||||
# `localhost/fc-desktop:linux-xfce` only exists on each node's
|
||||
# containerd after a manual import — it can't be pulled from a
|
||||
# registry, so a Deployment manifest in bluejay-infra would race the
|
||||
# image-import step and crash-loop).
|
||||
---
|
||||
# 1) desktop-isolation — Browser Lab session pods.
|
||||
#
|
||||
# Locks down pods labeled `app.kubernetes.io/name=remote-desktop` (every
|
||||
# session pod regardless of template). Allows guacd ingress for the VNC/RDP
|
||||
# display lane and remotedesktop-web's pre-handoff probing. Egress: NFS to
|
||||
# Synology, DNS, Traefik (cluster + LB VIP), Intranet (Browser Lab home).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: desktop-isolation
|
||||
namespace: fc-desktop
|
||||
labels:
|
||||
app.kubernetes.io/part-of: remotedesktop
|
||||
app.kubernetes.io/component: isolation
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: remote-desktop
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: guacamole
|
||||
ports:
|
||||
- port: 3000
|
||||
protocol: TCP
|
||||
- port: 3001
|
||||
protocol: TCP
|
||||
- port: 5901
|
||||
protocol: TCP
|
||||
- port: 3389
|
||||
protocol: TCP
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: fc-desktop
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: remotedesktop-web
|
||||
ports:
|
||||
- port: 3000
|
||||
protocol: TCP
|
||||
- port: 5901
|
||||
protocol: TCP
|
||||
egress:
|
||||
# NFS to Synology
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.3/32
|
||||
ports:
|
||||
- port: 2049
|
||||
protocol: TCP
|
||||
- port: 2049
|
||||
protocol: UDP
|
||||
- port: 111
|
||||
protocol: TCP
|
||||
- port: 111
|
||||
protocol: UDP
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.3/32
|
||||
ports:
|
||||
- port: 445
|
||||
protocol: TCP
|
||||
- to: []
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.56.200/32
|
||||
- ipBlock:
|
||||
cidr: 10.43.33.87/32
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
- port: 8000
|
||||
protocol: TCP
|
||||
- port: 8443
|
||||
protocol: TCP
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: intranet
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: intranet-web
|
||||
ports:
|
||||
- port: 5300
|
||||
protocol: TCP
|
||||
---
|
||||
# 2) fc-desktop-default-deny — namespace-wide catch-all.
|
||||
#
|
||||
# Selects every pod EXCEPT remotedesktop-web (the public-surface control
|
||||
# plane) and applies default-deny semantics for both Ingress and Egress.
|
||||
# Closes the gap where session pods land WITHOUT the desktop-isolation
|
||||
# policy's `app.kubernetes.io/name=remote-desktop` label, plus prevents
|
||||
# arbitrary debug sidecars / kubectl debug images from getting cluster
|
||||
# access.
|
||||
#
|
||||
# CRITICAL: also catches transient cm-acme-http-solver pods (that's the
|
||||
# bug this whole regroup chased). The cm-acme-http-solver-allow policy
|
||||
# below is the explicit carve-out.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: fc-desktop-default-deny
|
||||
namespace: fc-desktop
|
||||
labels:
|
||||
app.kubernetes.io/part-of: remotedesktop
|
||||
app.kubernetes.io/component: isolation
|
||||
spec:
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: NotIn
|
||||
values:
|
||||
- remotedesktop-web
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
---
|
||||
# 3) remotedesktop-web-isolation — control plane explicit allow-list.
|
||||
#
|
||||
# remotedesktop-web is the only pod label the default-deny excludes, so
|
||||
# without this policy the control plane would have wide-open Ingress AND
|
||||
# Egress. This re-introduces a tight allow-list:
|
||||
# - Ingress: Traefik only on TCP/8080
|
||||
# - Egress: CoreDNS, K8s API, Guacamole admin, NFS, Intranet,
|
||||
# Traefik (cluster + LB), and the fc-desktop namespace itself
|
||||
# (for session pod readiness probing).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: remotedesktop-web-isolation
|
||||
namespace: fc-desktop
|
||||
labels:
|
||||
app.kubernetes.io/part-of: remotedesktop
|
||||
app.kubernetes.io/component: isolation
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: remotedesktop-web
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
egress:
|
||||
# CoreDNS
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# K8s API server
|
||||
- to: []
|
||||
ports:
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
- port: 6443
|
||||
protocol: TCP
|
||||
# Guacamole admin
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: guacamole
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
# NFS to Synology
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.3/32
|
||||
ports:
|
||||
- port: 2049
|
||||
protocol: TCP
|
||||
- port: 2049
|
||||
protocol: UDP
|
||||
- port: 111
|
||||
protocol: TCP
|
||||
- port: 111
|
||||
protocol: UDP
|
||||
# Intranet web
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: intranet
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: intranet-web
|
||||
ports:
|
||||
- port: 5300
|
||||
protocol: TCP
|
||||
# Cluster Traefik pods (in-cluster service resolution + Guacamole
|
||||
# routing handoff where web app builds URLs against the public host
|
||||
# but resolves internally).
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
- port: 8443
|
||||
protocol: TCP
|
||||
# fc-desktop namespace — session pod probing during browser-access
|
||||
# readiness checks.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: fc-desktop
|
||||
ports:
|
||||
- port: 3000
|
||||
protocol: TCP
|
||||
- port: 3001
|
||||
protocol: TCP
|
||||
- port: 5901
|
||||
protocol: TCP
|
||||
- port: 3389
|
||||
protocol: TCP
|
||||
---
|
||||
# 4) cm-acme-http-solver-allow — cert-manager HTTP-01 carve-out.
|
||||
#
|
||||
# Without this, fc-desktop-default-deny catches the transient solver pods
|
||||
# cert-manager creates for each renewal (they don't carry the
|
||||
# remotedesktop-web label). Caused 8-day silent renewal failure on
|
||||
# desktop.iamworkin.lan in 2026-04-28..2026-05-07 (see
|
||||
# feedback_certmanager_renewal_stuck_when_solver_blocked_by_namespace_default_deny.md).
|
||||
#
|
||||
# Authorizes:
|
||||
# - Ingress on TCP/8089 from cluster Traefik (which proxies the external
|
||||
# HTTP-01 GET on port 80 through to the solver).
|
||||
# - Egress for cluster DNS (defensive — newer cert-manager probes from
|
||||
# inside the solver too).
|
||||
#
|
||||
# The `acme.cert-manager.io/http01-solver=true` label is set by
|
||||
# cert-manager itself on every solver pod automatically.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: cm-acme-http-solver-allow
|
||||
namespace: fc-desktop
|
||||
labels:
|
||||
app.kubernetes.io/part-of: remotedesktop
|
||||
app.kubernetes.io/component: cert-renewal
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
acme.cert-manager.io/http01-solver: "true"
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 8089
|
||||
protocol: TCP
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
26
apps/fc-devicemgmt/1password-item.yaml
Normal file
26
apps/fc-devicemgmt/1password-item.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Runtime secrets for FlowerCore.DeviceManagement.
|
||||
#
|
||||
# OnePasswordItem operator syncs this item into a Kubernetes Secret with the
|
||||
# same name. Expected fields:
|
||||
# DB-Password
|
||||
# mtls-ca.pem
|
||||
# mtls-client.crt
|
||||
# mtls-client.key
|
||||
# mtls-chain.pem
|
||||
#
|
||||
# Do not add literal secret values to this repo. Runtime pods consume the
|
||||
# synced Secret through env vars and read-only mounts.
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: fc-devicemgmt-runtime
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt
|
||||
app.kubernetes.io/component: secrets
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
spec:
|
||||
itemPath: "vaults/IAmWorkin/items/FlowerCore DeviceManagement Runtime"
|
||||
30
apps/fc-devicemgmt/certificate-web.yaml
Normal file
30
apps/fc-devicemgmt/certificate-web.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
# Certificate for devices.iamworkin.lan.
|
||||
#
|
||||
# Preflight gate: FlowerCore.DNS / pfSense must contain an explicit A record:
|
||||
# devices.iamworkin.lan -> 10.0.56.200
|
||||
# before this Certificate is synced. step-ca ACME cannot see the CoreDNS
|
||||
# wildcard, so missing pfSense DNS produces cert-manager HTTP-01 backoff
|
||||
# (feedback_pfsense_dns_required_for_acme).
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: fc-devicemgmt-web-tls
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt-web
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
annotations:
|
||||
flowercore.io/dns-preflight: "devices.iamworkin.lan must resolve to 10.0.56.200 before ACME sync"
|
||||
spec:
|
||||
secretName: fc-devicemgmt-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- devices.iamworkin.lan
|
||||
duration: 720h
|
||||
renewBefore: 240h
|
||||
81
apps/fc-devicemgmt/clusterrole-operator.yaml
Normal file
81
apps/fc-devicemgmt/clusterrole-operator.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: fc-devicemgmt-operator
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt-operator
|
||||
app.kubernetes.io/component: operator
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
rules:
|
||||
- apiGroups:
|
||||
- devices.flowercore.io
|
||||
resources:
|
||||
- '*'
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- create
|
||||
- update
|
||||
- patch
|
||||
- delete
|
||||
- apiGroups:
|
||||
- devices.flowercore.io
|
||||
resources:
|
||||
- devices/status
|
||||
- devices/finalizers
|
||||
- devicegroups/status
|
||||
- devicegroups/finalizers
|
||||
- devicepolicies/status
|
||||
- devicepolicies/finalizers
|
||||
- remotecommands/status
|
||||
- remotecommands/finalizers
|
||||
verbs:
|
||||
- get
|
||||
- update
|
||||
- patch
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- deployments
|
||||
verbs:
|
||||
- get
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- pods
|
||||
- services
|
||||
- configmaps
|
||||
- secrets
|
||||
- events
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- create
|
||||
- update
|
||||
- patch
|
||||
- delete
|
||||
- apiGroups:
|
||||
- batch
|
||||
resources:
|
||||
- jobs
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- create
|
||||
- update
|
||||
- patch
|
||||
- delete
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- networkpolicies
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
19
apps/fc-devicemgmt/clusterrolebinding-operator.yaml
Normal file
19
apps/fc-devicemgmt/clusterrolebinding-operator.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: fc-devicemgmt-operator
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt-operator
|
||||
app.kubernetes.io/component: operator
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: fc-devicemgmt-operator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: fc-devicemgmt-operator
|
||||
namespace: fc-devicemgmt
|
||||
109
apps/fc-devicemgmt/deployment-operator.yaml
Normal file
109
apps/fc-devicemgmt/deployment-operator.yaml
Normal file
@@ -0,0 +1,109 @@
|
||||
# FlowerCore.DeviceManagement Operator.
|
||||
#
|
||||
# KubeOps controller for devices.flowercore.io resources. Operator-created
|
||||
# children must set OwnerReferences + traceability labels/annotations per
|
||||
# k8s-pod-ownership-and-traceability-standard.md. RBAC below grants
|
||||
# apps/deployments/get so the process can resolve its own Deployment UID.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fc-devicemgmt-operator
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app: fc-devicemgmt-operator
|
||||
app.kubernetes.io/name: fc-devicemgmt-operator
|
||||
app.kubernetes.io/component: operator
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
annotations:
|
||||
flowercore.io/traceability-standard: k8s-pod-ownership-and-traceability-standard
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fc-devicemgmt-operator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fc-devicemgmt-operator
|
||||
app.kubernetes.io/name: fc-devicemgmt-operator
|
||||
app.kubernetes.io/component: operator
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
flowercore.io/audit-trace-id: "runtime-activity-trace"
|
||||
spec:
|
||||
serviceAccountName: fc-devicemgmt-operator
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: operator
|
||||
image: localhost/fc-devicemgmt-operator:v20260519-sp34cl3-fix
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 8080
|
||||
env:
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
|
||||
value: "false"
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: POD_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: FLOWERCORE_KUBERNETES_OWNER_DEPLOYMENT
|
||||
value: "fc-devicemgmt-operator"
|
||||
- name: FlowerCore__Service__Name
|
||||
value: "FlowerCore.DeviceManagement.Operator"
|
||||
- name: FlowerCore__DeviceManagement__DefaultTenantId
|
||||
value: "system"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 30
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1654
|
||||
runAsGroup: 1654
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
151
apps/fc-devicemgmt/deployment-web.yaml
Normal file
151
apps/fc-devicemgmt/deployment-web.yaml
Normal file
@@ -0,0 +1,151 @@
|
||||
# FlowerCore.DeviceManagement Web.
|
||||
#
|
||||
# Source repo is expected to ship FlowerCore.DeviceManagement.Web in a later
|
||||
# Sprint 9+ lane. This manifest is static-valid without requiring the image to
|
||||
# exist yet; import localhost/fc-devicemgmt-web:<tag> to all schedulable RKE2
|
||||
# nodes before letting ArgoCD sync a live rollout.
|
||||
#
|
||||
# SCALED TO 0 — 2026-05-19 morning-routine cleanup.
|
||||
# The Web pod cannot start until TWO upstream gaps close:
|
||||
# 1. MySQL DB instance `flowercore_devicemgmt` (user `fc_devicemgmt`) is
|
||||
# provisioned via fc-mysql Manager. The cluster currently has ZERO
|
||||
# MySqlInstanceCrds and no `mysql.fc-mysql.svc:3306` Service, so the
|
||||
# deployment-web container env `FlowerCore__Database__Host=mysql.fc-mysql.svc`
|
||||
# points at nothing. Provision via the fc-mysql Manager UI/REST/MCP.
|
||||
# 2. 1Password vault item `IAmWorkin/FlowerCore DeviceManagement Runtime`
|
||||
# with 5 fields (DB-Password, mtls-ca.pem, mtls-client.crt, mtls-client.key,
|
||||
# mtls-chain.pem) — see apps/fc-devicemgmt/1password-item.yaml. Mint mTLS
|
||||
# from step-ca-agent ClusterIssuer per ADR-126; DB-Password must match the
|
||||
# password configured for the MySQL user.
|
||||
# Re-enable: change replicas back to 2 after both gaps close. The image tag
|
||||
# in this file (v20260512-cx5) MAY also need a refresh — it predates the
|
||||
# Sprint 34 Cl-3 operator fix; Web may have an analogous bug.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fc-devicemgmt-web
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app: fc-devicemgmt-web
|
||||
app.kubernetes.io/name: fc-devicemgmt-web
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
annotations:
|
||||
flowercore.io/traceability-standard: k8s-pod-ownership-and-traceability-standard
|
||||
spec:
|
||||
replicas: 0
|
||||
revisionHistoryLimit: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fc-devicemgmt-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fc-devicemgmt-web
|
||||
app.kubernetes.io/name: fc-devicemgmt-web
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
flowercore.io/audit-trace-id: "runtime-activity-trace"
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: web
|
||||
image: localhost/fc-devicemgmt-web:v20260512-cx5
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
env:
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:8080"
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Production"
|
||||
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
|
||||
value: "false"
|
||||
- name: FlowerCore__Service__Name
|
||||
value: "FlowerCore.DeviceManagement.Web"
|
||||
- name: FlowerCore__DeviceManagement__DefaultTenantId
|
||||
value: "system"
|
||||
- name: FlowerCore__Database__Provider
|
||||
value: "MySql"
|
||||
- name: FlowerCore__Database__Host
|
||||
value: "mysql.fc-mysql.svc"
|
||||
- name: FlowerCore__Database__Database
|
||||
value: "flowercore_devicemgmt"
|
||||
- name: FlowerCore__Database__User
|
||||
value: "fc_devicemgmt"
|
||||
- name: FlowerCore__Database__Password
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fc-devicemgmt-runtime
|
||||
key: DB-Password
|
||||
- name: FlowerCore__DeviceManagement__AgentMtls__CaPath
|
||||
value: "/secrets/devicemgmt-mtls/mtls-ca.pem"
|
||||
- name: FlowerCore__DeviceManagement__AgentMtls__ClientCertificatePath
|
||||
value: "/secrets/devicemgmt-mtls/mtls-client.crt"
|
||||
- name: FlowerCore__DeviceManagement__AgentMtls__ClientKeyPath
|
||||
value: "/secrets/devicemgmt-mtls/mtls-client.key"
|
||||
- name: FlowerCore__EventBus__Redis__Configuration
|
||||
value: "redis.fc-redis.svc:6379"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 768Mi
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
failureThreshold: 30
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
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: tmp
|
||||
mountPath: /tmp
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
- name: devicemgmt-mtls
|
||||
mountPath: /secrets/devicemgmt-mtls
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
- name: devicemgmt-mtls
|
||||
secret:
|
||||
secretName: fc-devicemgmt-runtime
|
||||
defaultMode: 0400
|
||||
55
apps/fc-devicemgmt/ingressroute-web.yaml
Normal file
55
apps/fc-devicemgmt/ingressroute-web.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# LAN ingress for FlowerCore.DeviceManagement Web.
|
||||
#
|
||||
# RKE2 Traefik has no built-in ACME resolver configured. Keep TLS certificate
|
||||
# ownership in cert-manager Certificate/fc-devicemgmt-web-tls.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: fc-devicemgmt-web
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt-web
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`devices.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: fc-devicemgmt-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: fc-devicemgmt-web-tls
|
||||
|
||||
# Future public agent/update host gate (OFF by default):
|
||||
#
|
||||
# Do not enable `update.flowercore.io` here until Authentik OIDC Q-OIDC-1
|
||||
# resolves the public-device-management auth model and route ownership with
|
||||
# UpdateCenter. When enabled, use a separate public IngressRoute with an
|
||||
# explicit Method allowlist, public-host auth middleware, and public TLS
|
||||
# certificate strategy. Leaving this as comments keeps ArgoCD from stealing
|
||||
# live UpdateCenter traffic.
|
||||
#
|
||||
# apiVersion: traefik.io/v1alpha1
|
||||
# kind: IngressRoute
|
||||
# metadata:
|
||||
# name: fc-devicemgmt-web-public
|
||||
# namespace: fc-devicemgmt
|
||||
# annotations:
|
||||
# flowercore.io/public-host-gate: "disabled-until-Q-OIDC-1"
|
||||
# spec:
|
||||
# entryPoints:
|
||||
# - websecure
|
||||
# routes:
|
||||
# - match: Host(`update.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
|
||||
# kind: Rule
|
||||
# services:
|
||||
# - name: fc-devicemgmt-web
|
||||
# port: 80
|
||||
# tls:
|
||||
# secretName: fc-devicemgmt-public-tls
|
||||
13
apps/fc-devicemgmt/namespace.yaml
Normal file
13
apps/fc-devicemgmt/namespace.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
# FlowerCore.DeviceManagement namespace.
|
||||
#
|
||||
# ArgoCD discovers this directory as Application `infra-fc-devicemgmt`.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-devicemgmt
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
224
apps/fc-devicemgmt/network-policy.yaml
Normal file
224
apps/fc-devicemgmt/network-policy.yaml
Normal file
@@ -0,0 +1,224 @@
|
||||
# FlowerCore.DeviceManagement NetworkPolicies.
|
||||
#
|
||||
# NetworkPolicies belong in bluejay-infra so ArgoCD owns rebuild state.
|
||||
# Rules include Traefik post-DNAT backend ports per
|
||||
# feedback_netpol_dnat_backend_port and Synology NFS egress for the requested
|
||||
# cold-tier / future artifact path.
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: fc-devicemgmt-web-isolation
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt-web
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: fc-devicemgmt-web
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
# LAN edge: only cluster Traefik should reach the Web pod for
|
||||
# devices.iamworkin.lan.
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
# Direct LAN diagnostics are allowed only from FlowerCore LAN/VPN ranges.
|
||||
- from:
|
||||
- ipBlock:
|
||||
cidr: 10.0.56.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.57.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.68.0/27
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
egress:
|
||||
# CoreDNS.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# Database namespace.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: fc-mysql
|
||||
ports:
|
||||
- port: 3306
|
||||
protocol: TCP
|
||||
# Redis backplane for multi-replica SignalR / live-status fan-out.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: fc-redis
|
||||
ports:
|
||||
- port: 6379
|
||||
protocol: TCP
|
||||
# Traefik VIP / in-cluster Traefik for self-callbacks and public URL
|
||||
# generation tests. Include post-DNAT backend ports 8443 + 8080.
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.56.200/32
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
- port: 8443
|
||||
protocol: TCP
|
||||
# Agent egress: LAN/VPN devices may run DM Agent in Generic, Kiosk, Pi,
|
||||
# ThinClient, or Server mode. Keep this private-range only.
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.56.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.57.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.68.0/27
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
- port: 8443
|
||||
protocol: TCP
|
||||
- port: 5000
|
||||
protocol: TCP
|
||||
- port: 5001
|
||||
protocol: TCP
|
||||
# Synology NFS cold-tier / artifact mount allowance.
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.3/32
|
||||
ports:
|
||||
- port: 2049
|
||||
protocol: TCP
|
||||
- port: 2049
|
||||
protocol: UDP
|
||||
- port: 111
|
||||
protocol: TCP
|
||||
- port: 111
|
||||
protocol: UDP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: fc-devicemgmt-operator-isolation
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt-operator
|
||||
app.kubernetes.io/component: operator
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: fc-devicemgmt-operator
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: monitoring
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
egress:
|
||||
# CoreDNS.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
# Kubernetes API for KubeOps reconciliation and Deployment UID lookup.
|
||||
- to: []
|
||||
ports:
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
- port: 6443
|
||||
protocol: TCP
|
||||
# Agent egress for operator-initiated probes / fallback command dispatch.
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.56.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.57.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.0/24
|
||||
- ipBlock:
|
||||
cidr: 10.0.68.0/27
|
||||
ports:
|
||||
- port: 80
|
||||
protocol: TCP
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
- port: 8443
|
||||
protocol: TCP
|
||||
- port: 5000
|
||||
protocol: TCP
|
||||
- port: 5001
|
||||
protocol: TCP
|
||||
# Synology NFS allowance for future cold-tier/audit archival jobs.
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.0.58.3/32
|
||||
ports:
|
||||
- port: 2049
|
||||
protocol: TCP
|
||||
- port: 2049
|
||||
protocol: UDP
|
||||
- port: 111
|
||||
protocol: TCP
|
||||
- port: 111
|
||||
protocol: UDP
|
||||
22
apps/fc-devicemgmt/service-web.yaml
Normal file
22
apps/fc-devicemgmt/service-web.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fc-devicemgmt-web
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app: fc-devicemgmt-web
|
||||
app.kubernetes.io/name: fc-devicemgmt-web
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: fc-devicemgmt-web
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
12
apps/fc-devicemgmt/serviceaccount-operator.yaml
Normal file
12
apps/fc-devicemgmt/serviceaccount-operator.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: fc-devicemgmt-operator
|
||||
namespace: fc-devicemgmt
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-devicemgmt-operator
|
||||
app.kubernetes.io/component: operator
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
flowercore.io/tenant-id: system
|
||||
flowercore.io/created-by: bluejay-infra
|
||||
@@ -118,7 +118,7 @@ spec:
|
||||
# dotnet.exe publish -c Release -o deploy/app \
|
||||
# src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
|
||||
# podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy
|
||||
image: localhost/fc-distribution:v202604240010
|
||||
image: localhost/fc-distribution:v202605061948
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
@@ -151,6 +151,10 @@ spec:
|
||||
value: "/signing/aistation-field/chain.pem"
|
||||
- name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__KeyPath
|
||||
value: "/signing/aistation-field/private-key.pem"
|
||||
# Public distribution host is GET/HEAD-only at Traefik; this
|
||||
# entitlement list controls which editions are readable there.
|
||||
- name: FlowerCore__Distribution__EntitlementPublic__PublicEditions__0
|
||||
value: "*"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
@@ -262,8 +266,12 @@ spec:
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- dist.iamworkin.lan
|
||||
duration: 2160h # 90d
|
||||
renewBefore: 720h # 30d
|
||||
# step-ca ACME caps lifetime at 30d; requesting 90d silently capped
|
||||
# made renewBefore=cert-lifetime → perpetual renewal loop (10880+ CRs
|
||||
# in 18h on 2026-05-07). Match working 720h/240h pattern from other
|
||||
# FC services.
|
||||
duration: 720h # 30d (step-ca cap)
|
||||
renewBefore: 240h # 10d
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
|
||||
45
apps/fc-divoom-dm-pi-device/README.md
Normal file
45
apps/fc-divoom-dm-pi-device/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# FlowerCore Divoom DM Pi Device
|
||||
|
||||
Source-controlled Puppet/Hiera deployment contract for registering the edge2
|
||||
Divoom MiniToo panel as a FlowerCore DeviceManagement-managed Pi device.
|
||||
|
||||
This is not a Kubernetes application. The live panel remains the existing
|
||||
edge2 `flowercore-divoom.service` managed by `FlowerCore.Puppet`
|
||||
`profile::pi::service::divoom`, with the .NET payload deployed out of band
|
||||
and `/opt/flowercore/divoom/data` plus the Bluetooth shell wrappers preserved.
|
||||
Because edge2 is already Hiera-driven through `profile::pi::service::apps`,
|
||||
the deploy home is additive `profile::pi::service` data/profile source, not
|
||||
`profile::edge::service::apps` and not an ArgoCD/K8s app.
|
||||
|
||||
## Scope
|
||||
|
||||
- Stage DeviceManagement registration metadata for the edge2 Divoom MiniToo.
|
||||
- Stage a separate, disabled-by-default DM Agent executor unit for privileged
|
||||
Bluetooth operations once the DM-RPC lane lands.
|
||||
- Keep `flowercore-divoom.service` and `flowercore-divoom-bt.service`
|
||||
untouched: no service replacement, no restart subscription, no K8s surface.
|
||||
- Preserve the current wrapper contract:
|
||||
`/opt/flowercore/divoom/bt-link.sh`,
|
||||
`/opt/flowercore/divoom/bt-reset.sh`, and
|
||||
`/opt/flowercore/divoom/audio-link.sh`.
|
||||
- Keep FM radio disabled and require visible render proof; device-info echo is
|
||||
not render proof.
|
||||
|
||||
## Artifact Map
|
||||
|
||||
| Path | Use |
|
||||
| --- | --- |
|
||||
| `hiera/edge2-divoom-dm-device.overlay.yaml` | Additive Hiera overlay for edge2. Merge into the existing node YAML without removing `fc-pimanager` or `fc-divoom`. |
|
||||
| `puppet/profile/pi/service/divoom_dm_device.pp` | Puppet profile shape to vendor into `FlowerCore.Puppet` after the DM-RPC executor binary exists. |
|
||||
| `puppet/templates/divoom-device-registration.json.epp` | DM device registration metadata rendered on edge2. |
|
||||
| `puppet/templates/flowercore-divoom-dm-agent.service.epp` | Separate DM Agent systemd unit. Defaults are stopped and disabled until a later cutover. |
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
1. Land these artifacts in bluejay-infra as the deploy contract.
|
||||
2. Vendor the Puppet profile and EPP templates into `FlowerCore.Puppet`.
|
||||
3. Merge the Hiera overlay into `data/nodes/edge2.iamworkin.lan.yaml`.
|
||||
4. Run Puppet in noop first, preferably with a node-local validation directory
|
||||
under `~/.fcv` rather than `/tmp`.
|
||||
5. Only enable the DM Agent service after the DeviceManagement BT executor has
|
||||
landed and passed operator-eyeball render proof.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
# Merge into FlowerCore.Puppet data/nodes/edge2.iamworkin.lan.yaml.
|
||||
# Additive overlay only: keep the existing fc-pimanager version/tarball entry,
|
||||
# keep fc-divoom enabled, and do not move Divoom into Kubernetes.
|
||||
|
||||
profile::pi::service::apps:
|
||||
fc-pimanager:
|
||||
binary: 'FlowerCore.PiManager.Web'
|
||||
install_dir: '/opt/fc-pimanager'
|
||||
port: 5000
|
||||
environment: 'edge2'
|
||||
version: '2026.05.28.1646'
|
||||
tarball_source: 'puppet:///modules/profile/pi/builds/fc-pimanager.tar.gz'
|
||||
fc-divoom:
|
||||
enabled: true
|
||||
|
||||
profile::pi::service::divoom_dm_device::ensure: 'present'
|
||||
profile::pi::service::divoom_dm_device::service_enabled: false
|
||||
profile::pi::service::divoom_dm_device::service_ensure: 'stopped'
|
||||
profile::pi::service::divoom_dm_device::device_id: 'edge2-divoom-minitoo'
|
||||
profile::pi::service::divoom_dm_device::display_name: 'edge2 Divoom MiniToo'
|
||||
profile::pi::service::divoom_dm_device::host_fqdn: 'edge2.iamworkin.lan'
|
||||
profile::pi::service::divoom_dm_device::dm_web_url: 'https://devicemgmt.iamworkin.lan'
|
||||
profile::pi::service::divoom_dm_device::divoom_install_dir: '/opt/flowercore/divoom'
|
||||
profile::pi::service::divoom_dm_device::agent_install_dir: '/opt/flowercore/devicemanagement-agent'
|
||||
profile::pi::service::divoom_dm_device::bt_candidate_channels:
|
||||
- '1'
|
||||
- '10'
|
||||
profile::pi::service::divoom_dm_device::default_bt_channel: '1'
|
||||
profile::pi::service::divoom_dm_device::a2dp_default_state: 'off'
|
||||
profile::pi::service::divoom_dm_device::fm_radio_enabled: false
|
||||
profile::pi::service::divoom_dm_device::visible_render_proof_required: true
|
||||
@@ -0,0 +1,140 @@
|
||||
# Drop into FlowerCore.Puppet site-modules/profile/manifests/pi/service/divoom_dm_device.pp.
|
||||
# This profile is additive to profile::pi::service::divoom. It must not manage,
|
||||
# restart, replace, or subscribe the existing flowercore-divoom.service.
|
||||
class profile::pi::service::divoom_dm_device (
|
||||
Enum['present', 'absent'] $ensure = 'present',
|
||||
Boolean $service_enabled = false,
|
||||
Enum['running', 'stopped'] $service_ensure = 'stopped',
|
||||
String $service_name = 'flowercore-divoom-dm-agent',
|
||||
String $device_id = 'edge2-divoom-minitoo',
|
||||
String $display_name = 'edge2 Divoom MiniToo',
|
||||
String $host_fqdn = 'edge2.iamworkin.lan',
|
||||
String $dm_web_url = 'https://devicemgmt.iamworkin.lan',
|
||||
String $divoom_install_dir = '/opt/flowercore/divoom',
|
||||
String $agent_install_dir = '/opt/flowercore/devicemanagement-agent',
|
||||
String $agent_binary = 'FlowerCore.DeviceManagement.Agent',
|
||||
Array[String] $bt_candidate_channels = ['1', '10'],
|
||||
String $default_bt_channel = '1',
|
||||
Enum['on', 'off'] $a2dp_default_state = 'off',
|
||||
Boolean $fm_radio_enabled = false,
|
||||
Boolean $visible_render_proof_required = true,
|
||||
) {
|
||||
include profile::workstation::safe_account_exclusion
|
||||
|
||||
$safe_account = $profile::workstation::safe_account_exclusion::safe_account
|
||||
$config_dir = '/etc/flowercore/device-management/devices'
|
||||
$state_dir = '/var/lib/flowercore/divoom-dm-agent'
|
||||
$log_dir = '/var/log/flowercore/divoom-dm-agent'
|
||||
$registration_path = "${config_dir}/${device_id}.json"
|
||||
$agent_binary_path = "${agent_install_dir}/${agent_binary}"
|
||||
$bt_channels_json = inline_template('[<%= @bt_candidate_channels.map { |c| "\"#{c}\"" }.join(", ") %>]')
|
||||
|
||||
if $safe_account {
|
||||
notify { 'fc-divoom-dm-device safe-account exclusion':
|
||||
message => 'SAFE-ACCOUNT-EXCLUSION: Divoom DM Pi device profile refused to apply on operator workstation',
|
||||
}
|
||||
|
||||
if $facts['os']['family'] != 'windows' {
|
||||
ensure_resource('file', '/var/log/flowercore-audit', {
|
||||
'ensure' => 'directory',
|
||||
'owner' => 'root',
|
||||
'group' => 'root',
|
||||
'mode' => '0755',
|
||||
})
|
||||
|
||||
file { '/var/log/flowercore-audit/safe-account-noop-fc-divoom-dm-device.log':
|
||||
ensure => file,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0644',
|
||||
content => "noop: divoom dm pi device profile refused to apply on safe-account host\n",
|
||||
require => File['/var/log/flowercore-audit'],
|
||||
}
|
||||
}
|
||||
} elsif $ensure == 'absent' {
|
||||
service { $service_name:
|
||||
ensure => stopped,
|
||||
enable => false,
|
||||
}
|
||||
|
||||
file { [
|
||||
"/etc/systemd/system/${service_name}.service",
|
||||
$registration_path,
|
||||
]:
|
||||
ensure => absent,
|
||||
}
|
||||
|
||||
exec { 'fc-divoom-dm-agent-systemd-reload':
|
||||
command => '/usr/bin/systemctl daemon-reload',
|
||||
refreshonly => true,
|
||||
path => ['/usr/bin', '/bin'],
|
||||
}
|
||||
} else {
|
||||
case $facts['os']['family'] {
|
||||
'Debian': {}
|
||||
default: { fail("profile::pi::service::divoom_dm_device only supports Debian-family OS, got ${facts['os']['family']}") }
|
||||
}
|
||||
|
||||
file { [$config_dir, $state_dir, $log_dir]:
|
||||
ensure => directory,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0755',
|
||||
}
|
||||
|
||||
file { $registration_path:
|
||||
ensure => file,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0644',
|
||||
content => epp('profile/pi/fc_divoom_dm/divoom-device-registration.json.epp', {
|
||||
'device_id' => $device_id,
|
||||
'display_name' => $display_name,
|
||||
'host_fqdn' => $host_fqdn,
|
||||
'divoom_install_dir' => $divoom_install_dir,
|
||||
'bt_channels_json' => $bt_channels_json,
|
||||
'default_bt_channel' => $default_bt_channel,
|
||||
'a2dp_default_state' => $a2dp_default_state,
|
||||
'fm_radio_enabled' => $fm_radio_enabled,
|
||||
'visible_render_proof_required' => $visible_render_proof_required,
|
||||
}),
|
||||
require => File[$config_dir],
|
||||
}
|
||||
|
||||
file { "/etc/systemd/system/${service_name}.service":
|
||||
ensure => file,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0644',
|
||||
content => epp('profile/pi/fc_divoom_dm/flowercore-divoom-dm-agent.service.epp', {
|
||||
'service_name' => $service_name,
|
||||
'device_id' => $device_id,
|
||||
'dm_web_url' => $dm_web_url,
|
||||
'registration_path' => $registration_path,
|
||||
'divoom_install_dir' => $divoom_install_dir,
|
||||
'agent_install_dir' => $agent_install_dir,
|
||||
'agent_binary_path' => $agent_binary_path,
|
||||
'state_dir' => $state_dir,
|
||||
'log_dir' => $log_dir,
|
||||
}),
|
||||
notify => Exec['fc-divoom-dm-agent-systemd-reload'],
|
||||
require => File[$registration_path],
|
||||
}
|
||||
|
||||
exec { 'fc-divoom-dm-agent-systemd-reload':
|
||||
command => '/usr/bin/systemctl daemon-reload',
|
||||
refreshonly => true,
|
||||
path => ['/usr/bin', '/bin'],
|
||||
}
|
||||
|
||||
service { $service_name:
|
||||
ensure => $service_ensure,
|
||||
enable => $service_enabled,
|
||||
require => [
|
||||
File["/etc/systemd/system/${service_name}.service"],
|
||||
File[$registration_path],
|
||||
Exec['fc-divoom-dm-agent-systemd-reload'],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"deviceId": "<%= $device_id %>",
|
||||
"displayName": "<%= $display_name %>",
|
||||
"hostFqdn": "<%= $host_fqdn %>",
|
||||
"kind": "DivoomMiniToo",
|
||||
"managedBy": "FlowerCore.DeviceManagement",
|
||||
"executionMode": "Pi",
|
||||
"transport": {
|
||||
"kind": "BluetoothSerial",
|
||||
"candidateChannels": <%= $bt_channels_json %>,
|
||||
"defaultChannel": "<%= $default_bt_channel %>",
|
||||
"deviceInfoIsRenderProof": false,
|
||||
"visibleRenderProofRequired": <%= $visible_render_proof_required %>
|
||||
},
|
||||
"paths": {
|
||||
"divoomInstallDir": "<%= $divoom_install_dir %>",
|
||||
"btLink": "<%= $divoom_install_dir %>/bt-link.sh",
|
||||
"btReset": "<%= $divoom_install_dir %>/bt-reset.sh",
|
||||
"audioLink": "<%= $divoom_install_dir %>/audio-link.sh"
|
||||
},
|
||||
"capabilities": {
|
||||
"supportsBluetoothSerial": true,
|
||||
"supportsBtChannelRedetect": true,
|
||||
"supportsBtHardReset": true,
|
||||
"supportsBtAudioProfileSwitch": true,
|
||||
"a2dpDefaultState": "<%= $a2dp_default_state %>",
|
||||
"fmRadioEnabled": <%= $fm_radio_enabled %>
|
||||
},
|
||||
"safety": {
|
||||
"preserveExistingService": "flowercore-divoom.service",
|
||||
"preserveDataDirectory": "<%= $divoom_install_dir %>/data",
|
||||
"doNotEnableFmRadio": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Divoom DM Agent Bluetooth executor
|
||||
Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/divoom-tv-hdmi-multitarget-render-substrate.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target bluetooth.service
|
||||
Requires=bluetooth.service
|
||||
ConditionPathExists=<%= $agent_binary_path %>
|
||||
ConditionPathExists=<%= $registration_path %>
|
||||
ConditionPathExists=<%= $divoom_install_dir %>/bt-link.sh
|
||||
ConditionPathExists=<%= $divoom_install_dir %>/bt-reset.sh
|
||||
ConditionPathExists=<%= $divoom_install_dir %>/audio-link.sh
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stoltz
|
||||
Group=stoltz
|
||||
WorkingDirectory=<%= $agent_install_dir %>
|
||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
Environment=FLOWERCORE_DM_DEVICE_REGISTRATION=<%= $registration_path %>
|
||||
Environment=Divoom__Bluetooth__DeviceInfoIsRenderProof=false
|
||||
Environment=Divoom__Bluetooth__VisibleRenderProofRequired=true
|
||||
Environment=Divoom__Bluetooth__A2dpDefaultState=off
|
||||
ExecStart=<%= $agent_binary_path %> --mode=Pi --device-id=<%= $device_id %> --dm-web-url=<%= $dm_web_url %> --registration=<%= $registration_path %>
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
StartLimitBurst=3
|
||||
StartLimitIntervalSec=300s
|
||||
SupplementaryGroups=bluetooth audio dialout
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=<%= $state_dir %> <%= $log_dir %>
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
44
apps/fc-divoom-tv-pi/README.md
Normal file
44
apps/fc-divoom-tv-pi/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# FlowerCore Divoom TV Pi HDMI
|
||||
|
||||
Source-controlled deploy shape for the native `FlowerCore.Divoom.Tv`
|
||||
Avalonia HDMI renderer on a Raspberry Pi connected to a TV.
|
||||
|
||||
This is a Puppet/systemd appliance bundle, not a Kubernetes application. It
|
||||
mirrors the existing `fc-signage-pi-player` pattern: bluejay-infra carries the
|
||||
systemd units, scripts, Hiera shape, and Puppet profile source that
|
||||
`FlowerCore.Puppet` vendors and installs.
|
||||
|
||||
## Scope
|
||||
|
||||
- Launch the future `FlowerCore.Divoom.Tv` linux-arm64 self-contained payload
|
||||
from `/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv`.
|
||||
- Prefer `cage` as the Wayland fullscreen compositor, with direct app launch as
|
||||
a fallback for development images.
|
||||
- Restart the app after HDMI hotplug with a 2 second DRM settle delay.
|
||||
- Keep all runtime state local: `/var/lib/fc-divoom-tv` and
|
||||
`/var/log/fc-divoom-tv`.
|
||||
- Avoid CDN/runtime fetches; the app renders the in-house Divoom scene catalog
|
||||
locally.
|
||||
|
||||
## Artifact Map
|
||||
|
||||
| Path | Use |
|
||||
| --- | --- |
|
||||
| `systemd/flowercore-divoom-tv.service` | Fullscreen Avalonia HDMI app service. |
|
||||
| `systemd/flowercore-divoom-tv-hdmi.service` | HDMI hotplug responder service. |
|
||||
| `systemd/99-flowercore-divoom-tv-hdmi.rules` | DRM udev hotplug rule. |
|
||||
| `scripts/flowercore-divoom-tv-prelaunch.sh` | Preflight checks and local directory creation. |
|
||||
| `scripts/flowercore-divoom-tv-launch.sh` | Cage-first fullscreen launcher. |
|
||||
| `scripts/flowercore-divoom-tv-hdmi-respond.sh` | Hotplug settle and restart script. |
|
||||
| `puppet/profile/pi/service/divoom_tv.pp` | Puppet profile shape to vendor into `FlowerCore.Puppet`. |
|
||||
| `hiera/example-divoom-tv-pi.iamworkin.lan.yaml` | Example node Hiera for a Divoom TV Pi. |
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
1. Build `FlowerCore.Divoom.Tv` with `dotnet.exe publish -c Release -r linux-arm64 --self-contained`.
|
||||
2. Stage the payload to `/opt/flowercore/divoom-tv/` through the standard noc1
|
||||
jump path and avoid `/tmp` for unprivileged Pi scratch.
|
||||
3. Vendor the profile and static files into `FlowerCore.Puppet`.
|
||||
4. Run Puppet noop, then apply on the target Pi.
|
||||
5. Prove deployment with `systemctl is-active flowercore-divoom-tv.service`,
|
||||
journal lines showing frames presented, and a visible HDMI display check.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# Example node data for a dedicated Pi -> HDMI -> TV Divoom renderer.
|
||||
# Copy into FlowerCore.Puppet data/nodes/<hostname>.iamworkin.lan.yaml only
|
||||
# after the Pi has a static DHCP/DNS entry and the linux-arm64 payload exists.
|
||||
|
||||
facts:
|
||||
role: pi_prototype
|
||||
|
||||
profile::motd::role: 'Divoom TV HDMI Renderer'
|
||||
|
||||
profile::pi::service::divoom_tv::ensure: 'present'
|
||||
profile::pi::service::divoom_tv::service_enabled: true
|
||||
profile::pi::service::divoom_tv::service_ensure: 'running'
|
||||
profile::pi::service::divoom_tv::install_dir: '/opt/flowercore/divoom-tv'
|
||||
profile::pi::service::divoom_tv::state_dir: '/var/lib/fc-divoom-tv'
|
||||
profile::pi::service::divoom_tv::log_dir: '/var/log/fc-divoom-tv'
|
||||
profile::pi::service::divoom_tv::presentation_mode: 'PillarboxSquare'
|
||||
profile::pi::service::divoom_tv::startup_scene: 'bluejay-clock'
|
||||
profile::pi::service::divoom_tv::reduced_motion: false
|
||||
149
apps/fc-divoom-tv-pi/puppet/profile/pi/service/divoom_tv.pp
Normal file
149
apps/fc-divoom-tv-pi/puppet/profile/pi/service/divoom_tv.pp
Normal file
@@ -0,0 +1,149 @@
|
||||
# Drop into FlowerCore.Puppet site-modules/profile/manifests/pi/service/divoom_tv.pp.
|
||||
# Static files come from profile/pi/fc_divoom_tv/ after this bluejay-infra
|
||||
# bundle is vendored into the Puppet control repo.
|
||||
class profile::pi::service::divoom_tv (
|
||||
Enum['present', 'absent'] $ensure = 'present',
|
||||
Boolean $service_enabled = false,
|
||||
Enum['running', 'stopped'] $service_ensure = 'stopped',
|
||||
String $service_name = 'flowercore-divoom-tv',
|
||||
String $user = 'fc-divoom-tv',
|
||||
String $group = 'fc-divoom-tv',
|
||||
String $install_dir = '/opt/flowercore/divoom-tv',
|
||||
String $state_dir = '/var/lib/fc-divoom-tv',
|
||||
String $log_dir = '/var/log/fc-divoom-tv',
|
||||
String $presentation_mode = 'PillarboxSquare',
|
||||
String $startup_scene = 'bluejay-clock',
|
||||
Boolean $reduced_motion = false,
|
||||
) {
|
||||
include profile::workstation::safe_account_exclusion
|
||||
|
||||
$safe_account = $profile::workstation::safe_account_exclusion::safe_account
|
||||
|
||||
if $safe_account {
|
||||
notify { 'fc-divoom-tv safe-account exclusion':
|
||||
message => 'SAFE-ACCOUNT-EXCLUSION: Divoom TV Pi profile refused to apply on operator workstation',
|
||||
}
|
||||
} elsif $ensure == 'absent' {
|
||||
service { $service_name:
|
||||
ensure => stopped,
|
||||
enable => false,
|
||||
}
|
||||
|
||||
file { [
|
||||
"/etc/systemd/system/${service_name}.service",
|
||||
"/etc/systemd/system/${service_name}-hdmi.service",
|
||||
'/etc/udev/rules.d/99-flowercore-divoom-tv-hdmi.rules',
|
||||
'/usr/local/bin/flowercore-divoom-tv-prelaunch.sh',
|
||||
'/usr/local/bin/flowercore-divoom-tv-launch.sh',
|
||||
'/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh',
|
||||
'/etc/flowercore/divoom-tv.env',
|
||||
]:
|
||||
ensure => absent,
|
||||
}
|
||||
} else {
|
||||
case $facts['os']['family'] {
|
||||
'Debian': {}
|
||||
default: { fail("profile::pi::service::divoom_tv only supports Debian-family OS, got ${facts['os']['family']}") }
|
||||
}
|
||||
|
||||
package { ['cage', 'libgbm1', 'libdrm2', 'libxkbcommon0', 'fonts-dejavu-core']:
|
||||
ensure => installed,
|
||||
}
|
||||
|
||||
group { $group:
|
||||
ensure => present,
|
||||
system => true,
|
||||
}
|
||||
|
||||
user { $user:
|
||||
ensure => present,
|
||||
system => true,
|
||||
gid => $group,
|
||||
home => $state_dir,
|
||||
managehome => false,
|
||||
shell => '/usr/sbin/nologin',
|
||||
require => Group[$group],
|
||||
}
|
||||
|
||||
file { [$install_dir, $state_dir, $log_dir, '/etc/flowercore']:
|
||||
ensure => directory,
|
||||
owner => $user,
|
||||
group => $group,
|
||||
mode => '0755',
|
||||
}
|
||||
|
||||
file { '/etc/flowercore/divoom-tv.env':
|
||||
ensure => file,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0644',
|
||||
content => "FC_DIVOOM_TV_PRESENTATION_MODE=${presentation_mode}\nFC_DIVOOM_TV_START_SCENE=${startup_scene}\nFC_DIVOOM_TV_REDUCED_MOTION=${reduced_motion}\n",
|
||||
require => File['/etc/flowercore'],
|
||||
}
|
||||
|
||||
$script_map = {
|
||||
'/usr/local/bin/flowercore-divoom-tv-prelaunch.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-prelaunch.sh',
|
||||
'/usr/local/bin/flowercore-divoom-tv-launch.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-launch.sh',
|
||||
'/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-hdmi-respond.sh',
|
||||
}
|
||||
|
||||
$script_map.each |$dest, $src| {
|
||||
file { $dest:
|
||||
ensure => file,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0755',
|
||||
source => "puppet:///modules/${src}",
|
||||
}
|
||||
}
|
||||
|
||||
$unit_map = {
|
||||
"/etc/systemd/system/${service_name}.service" => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv.service',
|
||||
"/etc/systemd/system/${service_name}-hdmi.service" => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-hdmi.service',
|
||||
}
|
||||
|
||||
$unit_map.each |$dest, $src| {
|
||||
file { $dest:
|
||||
ensure => file,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0644',
|
||||
source => "puppet:///modules/${src}",
|
||||
notify => Exec['fc-divoom-tv-systemd-reload'],
|
||||
}
|
||||
}
|
||||
|
||||
file { '/etc/udev/rules.d/99-flowercore-divoom-tv-hdmi.rules':
|
||||
ensure => file,
|
||||
owner => 'root',
|
||||
group => 'root',
|
||||
mode => '0644',
|
||||
source => 'puppet:///modules/profile/pi/fc_divoom_tv/99-flowercore-divoom-tv-hdmi.rules',
|
||||
notify => Exec['fc-divoom-tv-udev-reload'],
|
||||
}
|
||||
|
||||
exec { 'fc-divoom-tv-systemd-reload':
|
||||
command => '/usr/bin/systemctl daemon-reload',
|
||||
refreshonly => true,
|
||||
path => ['/usr/bin', '/bin'],
|
||||
}
|
||||
|
||||
exec { 'fc-divoom-tv-udev-reload':
|
||||
command => '/usr/bin/udevadm control --reload-rules',
|
||||
refreshonly => true,
|
||||
path => ['/usr/bin', '/bin'],
|
||||
}
|
||||
|
||||
service { $service_name:
|
||||
ensure => $service_ensure,
|
||||
enable => $service_enabled,
|
||||
require => [
|
||||
File["/etc/systemd/system/${service_name}.service"],
|
||||
File['/etc/flowercore/divoom-tv.env'],
|
||||
File['/usr/local/bin/flowercore-divoom-tv-prelaunch.sh'],
|
||||
File['/usr/local/bin/flowercore-divoom-tv-launch.sh'],
|
||||
Exec['fc-divoom-tv-systemd-reload'],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sleep 2
|
||||
systemctl restart flowercore-divoom-tv.service
|
||||
25
apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-launch.sh
Normal file
25
apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-launch.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_BIN="${FC_DIVOOM_TV_BIN:-/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv}"
|
||||
STATE_DIR="${FC_DIVOOM_TV_STATE_DIR:-/var/lib/fc-divoom-tv}"
|
||||
LOG_DIR="${FC_DIVOOM_TV_LOG_DIR:-/var/log/fc-divoom-tv}"
|
||||
PRESENTATION_MODE="${FC_DIVOOM_TV_PRESENTATION_MODE:-PillarboxSquare}"
|
||||
START_SCENE="${FC_DIVOOM_TV_START_SCENE:-bluejay-clock}"
|
||||
REDUCED_MOTION="${FC_DIVOOM_TV_REDUCED_MOTION:-false}"
|
||||
|
||||
COMMON_ARGS=(
|
||||
"--target=hdmi"
|
||||
"--presentation-mode=${PRESENTATION_MODE}"
|
||||
"--startup-scene=${START_SCENE}"
|
||||
"--reduced-motion=${REDUCED_MOTION}"
|
||||
"--state-dir=${STATE_DIR}"
|
||||
"--log-dir=${LOG_DIR}"
|
||||
)
|
||||
|
||||
if command -v cage >/dev/null 2>&1; then
|
||||
exec cage -- "${APP_BIN}" "${COMMON_ARGS[@]}" "$@"
|
||||
fi
|
||||
|
||||
echo "[$(date -Is)] cage not found; launching FlowerCore.Divoom.Tv directly" >&2
|
||||
exec "${APP_BIN}" "${COMMON_ARGS[@]}" "$@"
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_BIN="${FC_DIVOOM_TV_BIN:-/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv}"
|
||||
STATE_DIR="${FC_DIVOOM_TV_STATE_DIR:-/var/lib/fc-divoom-tv}"
|
||||
LOG_DIR="${FC_DIVOOM_TV_LOG_DIR:-/var/log/fc-divoom-tv}"
|
||||
|
||||
mkdir -p "${STATE_DIR}" "${LOG_DIR}"
|
||||
|
||||
if [[ ! -x "${APP_BIN}" ]]; then
|
||||
echo "[$(date -Is)] missing executable ${APP_BIN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -d /sys/class/drm ]] && ! find /sys/class/drm -maxdepth 1 -name 'card*-HDMI-A-*' -print -quit | grep -q .; then
|
||||
echo "[$(date -Is)] no HDMI connector visible yet; continuing so the app can wait for display" >&2
|
||||
fi
|
||||
|
||||
if command -v cage >/dev/null 2>&1; then
|
||||
echo "[$(date -Is)] cage available for fullscreen Wayland launch"
|
||||
else
|
||||
echo "[$(date -Is)] cage not installed; direct launch fallback will be used" >&2
|
||||
fi
|
||||
@@ -0,0 +1,2 @@
|
||||
# Settle DRM for 2s before restarting the fullscreen Avalonia renderer.
|
||||
SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl start flowercore-divoom-tv-hdmi.service"
|
||||
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Divoom TV HDMI hotplug responder
|
||||
DefaultDependencies=no
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh
|
||||
40
apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv.service
Normal file
40
apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv.service
Normal file
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Divoom TV HDMI Renderer (Avalonia fullscreen)
|
||||
Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/divoom-tv-hdmi-multitarget-render-substrate.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target systemd-user-sessions.service
|
||||
ConditionPathExists=/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=fc-divoom-tv
|
||||
Group=fc-divoom-tv
|
||||
WorkingDirectory=/opt/flowercore/divoom-tv
|
||||
EnvironmentFile=-/etc/flowercore/divoom-tv.env
|
||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
Environment=XDG_RUNTIME_DIR=/run/fc-divoom-tv
|
||||
RuntimeDirectory=fc-divoom-tv
|
||||
RuntimeDirectoryMode=0700
|
||||
ExecStartPre=/usr/local/bin/flowercore-divoom-tv-prelaunch.sh
|
||||
ExecStart=/usr/local/bin/flowercore-divoom-tv-launch.sh
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
StartLimitBurst=5
|
||||
StartLimitIntervalSec=300s
|
||||
MemoryMax=2G
|
||||
MemoryHigh=1500M
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/fc-divoom-tv /var/log/fc-divoom-tv /run/fc-divoom-tv
|
||||
TTYPath=/dev/tty1
|
||||
StandardInput=tty
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
TTYVTDisallocate=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical.target
|
||||
@@ -87,6 +87,20 @@ spec:
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
# Use an explicit DNS policy so external FQDNs like api.anthropic.com are
|
||||
# resolved directly instead of being expanded through the cluster search
|
||||
# path that includes iamworkin.lan.
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- 10.43.0.10
|
||||
searches:
|
||||
- fc-llm-bridge.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- name: ndots
|
||||
value: "2"
|
||||
securityContext:
|
||||
fsGroup: 1654
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
@@ -97,7 +111,7 @@ spec:
|
||||
# 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:v202604231520
|
||||
image: localhost/fc-llm-bridge:v202604300022
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
@@ -116,6 +130,10 @@ spec:
|
||||
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.
|
||||
@@ -207,17 +225,6 @@ spec:
|
||||
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:
|
||||
|
||||
@@ -69,16 +69,14 @@ spec:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
tcpSocket:
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
|
||||
171
apps/fc-redis/fc-redis.yaml
Normal file
171
apps/fc-redis/fc-redis.yaml
Normal file
@@ -0,0 +1,171 @@
|
||||
# fc-redis — SignalR backplane for cross-product event bus
|
||||
#
|
||||
# Lands per Q-SO-1 resolution (2026-05-11 PM): SignalR backplane in Phase A,
|
||||
# not Phase C as originally drafted. Operator directive: "Redis can be
|
||||
# deployed just fine as it's another FlowerCore technology we'll want to
|
||||
# manage."
|
||||
#
|
||||
# Phase A scope (this file):
|
||||
# - Single Redis 7.x Alpine pod
|
||||
# - 1Gi Longhorn RWO PVC for AOF persistence
|
||||
# - ClusterIP Service at `redis.fc-redis.svc.cluster.local:6379`
|
||||
# - No AUTH (in-cluster only; not exposed externally)
|
||||
# - No IngressRoute (backplane is server-to-server only)
|
||||
#
|
||||
# Consumers (Phase A IMPL across FC services):
|
||||
# - FlowerCore.Signage.Web (OpsConsoleHub)
|
||||
# - FlowerCore.Scoreboard.Web (ScoreboardHub)
|
||||
# - FlowerCore.SignalControl.Web
|
||||
# - FlowerCore.DMS.Web
|
||||
# - Any other product joining the cross-product event bus
|
||||
#
|
||||
# Each consumer adds:
|
||||
# services.AddSignalR()
|
||||
# .AddStackExchangeRedis(
|
||||
# "redis.fc-redis.svc.cluster.local:6379",
|
||||
# opts => opts.Configuration.ChannelPrefix =
|
||||
# StackExchange.Redis.RedisChannel.Literal("fc-opsconsole"));
|
||||
#
|
||||
# Phase B / C follow-ons (out of scope here):
|
||||
# - Redis Sentinel for HA (3-node)
|
||||
# - AUTH password from 1Password Connect (rotate via /rotate-password)
|
||||
# - redis_exporter sidecar for Prometheus scrape
|
||||
# - Network policies restricting which namespaces can dial 6379
|
||||
#
|
||||
# Design: docs/signage/operations-console-phase-2-design.md §3.5
|
||||
# Decision: Q-SO-1 (RESOLVED 2026-05-11 PM)
|
||||
# Memory: feedback_blooming_ui_pattern_no_iframes
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-redis
|
||||
labels:
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
app.kubernetes.io/managed-by: argocd
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: fc-redis-data
|
||||
namespace: fc-redis
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: fc-redis-config
|
||||
namespace: fc-redis
|
||||
data:
|
||||
redis.conf: |
|
||||
# Phase A — minimal config; no AUTH, no replication.
|
||||
bind 0.0.0.0
|
||||
protected-mode no
|
||||
port 6379
|
||||
tcp-backlog 511
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
|
||||
# Persistence: AOF (fsync every second is the standard SignalR-backplane
|
||||
# durability sweet spot — the backplane only needs to survive Redis
|
||||
# restarts, not absolute zero loss).
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
|
||||
# Reasonable defaults — let Redis pick most things.
|
||||
maxmemory-policy allkeys-lru
|
||||
maxmemory 256mb
|
||||
|
||||
# Logging
|
||||
loglevel notice
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fc-redis
|
||||
namespace: fc-redis
|
||||
labels:
|
||||
app: fc-redis
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate # RWO PVC; do not do rolling update
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fc-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fc-redis
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999 # redis:7-alpine default uid
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["redis-server", "/etc/redis/redis.conf"]
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
resources:
|
||||
requests:
|
||||
cpu: "50m"
|
||||
memory: "128Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "384Mi"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: config
|
||||
mountPath: /etc/redis
|
||||
readOnly: true
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 6379
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: fc-redis-data
|
||||
- name: config
|
||||
configMap:
|
||||
name: fc-redis-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: fc-redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: fc-redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
protocol: TCP
|
||||
14
apps/fc-signage-appletv/README.md
Normal file
14
apps/fc-signage-appletv/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# fc-signage-appletv
|
||||
|
||||
Apple TV signage is a sealed appliance running the `FlowerCore.Signage.Agent.AppleTv` tvOS app per ADR-134.
|
||||
|
||||
This ApplicationSet entry is documentation and inventory metadata only. It intentionally creates no `Deployment`, `Service`, or `Pod`.
|
||||
|
||||
The Apple TV app connects outbound to existing FC.Signage.Web surfaces:
|
||||
|
||||
- `https://signage.iamworkin.lan/hub/signage` for SignalR live status.
|
||||
- `GET /api/v1/nodes/{nodeId}/state` for the 30 second polling fallback.
|
||||
- `POST /api/v1/nodes/register` and `POST /api/v1/nodes/{nodeId}/enroll` for pairing and mTLS enrollment.
|
||||
- `POST /api/v1/nodes/{nodeId}/heartbeat` for metrics, current content identity, and local audit excerpts.
|
||||
|
||||
Distribution is via Apple Developer Enterprise Program or TestFlight plus FC.Distribution / UpdateCenter publishing once Apple credentials are available.
|
||||
5
apps/fc-signage-appletv/kustomization.yaml
Normal file
5
apps/fc-signage-appletv/kustomization.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- manifest.yaml
|
||||
26
apps/fc-signage-appletv/manifest.yaml
Normal file
26
apps/fc-signage-appletv/manifest.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Apple TV signage is a sealed tvOS appliance. This ArgoCD app intentionally
|
||||
# carries documentation metadata only; no Deployment, Service, or Pod resources
|
||||
# are created for the player.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: fc-signage-appletv-docs
|
||||
namespace: fc-signage
|
||||
labels:
|
||||
app.kubernetes.io/name: fc-signage-appletv
|
||||
app.kubernetes.io/part-of: flowercore-signage
|
||||
flowercore.io/manifest-kind: docs-only
|
||||
data:
|
||||
README: |
|
||||
FlowerCore.Signage.Agent.AppleTv is distributed through Apple Developer
|
||||
Enterprise Program or TestFlight, not Kubernetes.
|
||||
|
||||
The app connects outbound to FC.Signage.Web:
|
||||
- SignalR: https://signage.iamworkin.lan/hub/signage
|
||||
- Polling fallback: GET /api/v1/nodes/{nodeId}/state
|
||||
- Enrollment: POST /api/v1/nodes/{nodeId}/enroll
|
||||
- Heartbeat: POST /api/v1/nodes/{nodeId}/heartbeat
|
||||
|
||||
This placeholder gives ArgoCD and inventory dashboards a first-class
|
||||
Apple TV signage app entry without creating runtime pods.
|
||||
17
apps/fc-signage-pi-player/README.md
Normal file
17
apps/fc-signage-pi-player/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# FlowerCore Signage Pi Player
|
||||
|
||||
Phase 1 Raspberry Pi signage player packaging for Chromium kiosk deployments.
|
||||
This bundle is intentionally air-gap friendly: systemd units, shell scripts,
|
||||
udev rules, and Chromium managed policy are all checked into the repo and are
|
||||
installed by `FlowerCore.Puppet`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Bootstrap a stable node identity and mTLS client certificate.
|
||||
- Launch Chromium in kiosk mode against `FC.Signage.Web` player routes.
|
||||
- Restart the kiosk on HDMI hotplug.
|
||||
- Renew mTLS certificates daily when fewer than 30 days remain.
|
||||
- Detect display capabilities at boot, daily, and on HDMI hotplug.
|
||||
|
||||
Phase 2 native Avalonia rendering is documented separately in Notes and remains
|
||||
deferred.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"AutofillAddressEnabled": false,
|
||||
"AutofillCreditCardEnabled": false,
|
||||
"PasswordManagerEnabled": false,
|
||||
"BrowserSignin": 0,
|
||||
"MetricsReportingEnabled": false,
|
||||
"SafeBrowsingProtectionLevel": 0,
|
||||
"DefaultNotificationsSetting": 2,
|
||||
"DefaultPopupsSetting": 2,
|
||||
"BackgroundModeEnabled": false,
|
||||
"DefaultBrowserSettingEnabled": false,
|
||||
"PromotionalTabsEnabled": false,
|
||||
"CommandLineFlagSecurityWarningsEnabled": false,
|
||||
"ExtensionInstallBlocklist": ["*"]
|
||||
}
|
||||
132
apps/fc-signage-pi-player/scripts/fc-signage-detect-display
Normal file
132
apps/fc-signage-pi-player/scripts/fc-signage-detect-display
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NODE_JSON="/etc/flowercore/signage-node.json"
|
||||
CERT_DIR="/etc/fc-signage-player"
|
||||
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
|
||||
NODE_ID=$(jq -r '.nodeId' "$NODE_JSON")
|
||||
|
||||
CONNECTORS=()
|
||||
for dir in /sys/class/drm/card*-HDMI-A-*; do
|
||||
[[ -e "$dir/status" ]] || continue
|
||||
if [[ "$(cat "$dir/status")" == "connected" ]]; then
|
||||
CONNECTORS+=("$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#CONNECTORS[@]} -eq 0 ]]; then
|
||||
CAPABILITIES_JSON=$(jq -n --arg id "$NODE_ID" '{
|
||||
nodeId: $id,
|
||||
platform: "linux-arm64-pi",
|
||||
displayConnected: false,
|
||||
detectedAt: (now | todate),
|
||||
note: "No HDMI display detected"
|
||||
}')
|
||||
else
|
||||
PRIMARY="${CONNECTORS[0]}"
|
||||
EDID_PATH="/sys/class/drm/${PRIMARY}/edid"
|
||||
WIDTH=0
|
||||
HEIGHT=0
|
||||
REFRESH=60
|
||||
HDR=false
|
||||
AUDIO_HDMI=false
|
||||
MFG=""
|
||||
MODEL=""
|
||||
PHYSICAL_SIZE=null
|
||||
|
||||
if [[ -s "$EDID_PATH" ]] && command -v edid-decode >/dev/null 2>&1; then
|
||||
EDID_INFO=$(edid-decode < "$EDID_PATH" 2>/dev/null || true)
|
||||
MFG=$(echo "$EDID_INFO" | grep -m1 -oP 'Manufacturer:\s*\K\S+' || true)
|
||||
MODEL=$(echo "$EDID_INFO" | grep -m1 -oP 'Model:\s*\K\S+' || true)
|
||||
PREF=$(echo "$EDID_INFO" | grep -m1 -oP '\d+x\d+\s*@\s*\d+(?:\.\d+)?\s*Hz' || true)
|
||||
if [[ -n "$PREF" ]]; then
|
||||
WIDTH=$(echo "$PREF" | grep -oP '^\d+')
|
||||
HEIGHT=$(echo "$PREF" | grep -oP 'x\K\d+')
|
||||
REFRESH=$(echo "$PREF" | grep -oP '@\s*\K[\d.]+' | cut -d. -f1)
|
||||
fi
|
||||
if echo "$EDID_INFO" | grep -qiE 'HDR (Static|Dynamic) Metadata Block'; then HDR=true; fi
|
||||
if echo "$EDID_INFO" | grep -qiE 'CEA Audio Block|Audio Format Descriptor'; then AUDIO_HDMI=true; fi
|
||||
PH_W=$(echo "$EDID_INFO" | grep -m1 -oP 'Maximum image size:\s*\K\d+\s*cm\s*x\s*\d+' || true)
|
||||
if [[ -n "$PH_W" ]]; then
|
||||
PH_CM_W=$(echo "$PH_W" | grep -oP '^\d+')
|
||||
PH_CM_H=$(echo "$PH_W" | grep -oP 'x\s*\K\d+')
|
||||
if (( PH_CM_W > 0 && PH_CM_H > 0 )); then
|
||||
PHYSICAL_SIZE=$(awk -v w="$PH_CM_W" -v h="$PH_CM_H" 'BEGIN { printf "%.1f", sqrt(w*w + h*h)/2.54 }')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$WIDTH" == "0" ]] && command -v kmsprint >/dev/null 2>&1; then
|
||||
KMS=$(kmsprint 2>/dev/null | grep -A2 "$PRIMARY" | grep -oP '\d+x\d+' | head -1 || true)
|
||||
if [[ -n "$KMS" ]]; then
|
||||
WIDTH=$(echo "$KMS" | grep -oP '^\d+')
|
||||
HEIGHT=$(echo "$KMS" | grep -oP 'x\K\d+')
|
||||
fi
|
||||
fi
|
||||
|
||||
AUDIO_ALSA=false
|
||||
if aplay -l 2>/dev/null | grep -qi 'card.*HDMI'; then AUDIO_ALSA=true; fi
|
||||
HAS_AUDIO=false
|
||||
if [[ "$AUDIO_HDMI" == "true" && "$AUDIO_ALSA" == "true" ]]; then HAS_AUDIO=true; fi
|
||||
|
||||
CAPABILITIES_JSON=$(jq -n \
|
||||
--arg id "$NODE_ID" \
|
||||
--argjson w "$WIDTH" \
|
||||
--argjson h "$HEIGHT" \
|
||||
--argjson r "$REFRESH" \
|
||||
--argjson hdr "$HDR" \
|
||||
--argjson audio "$HAS_AUDIO" \
|
||||
--arg connector "$PRIMARY" \
|
||||
--arg mfg "$MFG" \
|
||||
--arg model "$MODEL" \
|
||||
--argjson size "$PHYSICAL_SIZE" \
|
||||
'{
|
||||
nodeId: $id,
|
||||
platform: "linux-arm64-pi",
|
||||
displayConnected: true,
|
||||
detectedAt: (now | todate),
|
||||
hardware: {
|
||||
maxResolution: { width: $w, height: $h },
|
||||
nativeResolution: { width: $w, height: $h },
|
||||
refreshRateHz: $r,
|
||||
colorDepth: ($hdr | if . then "Color30Hdr" else "Color24" end),
|
||||
hasAudioOutput: $audio,
|
||||
audioChannelCount: ($audio | if . then 2 else 0 end),
|
||||
physicalSizeInches: $size,
|
||||
connector: $connector,
|
||||
manufacturer: $mfg,
|
||||
modelName: $model
|
||||
},
|
||||
render: { codecs: ["h264", "vp9", "mp4"] }
|
||||
}')
|
||||
fi
|
||||
|
||||
ENDPOINT_CANDIDATES=(
|
||||
"${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/capabilities"
|
||||
"${SIGNAGE_URL}/api/v1/displays/${NODE_ID}/capability-profile"
|
||||
)
|
||||
|
||||
SUCCESS=false
|
||||
for url in "${ENDPOINT_CANDIDATES[@]}"; do
|
||||
HTTP_STATUS=$(curl -sk -o /tmp/cap-response.json -w "%{http_code}" \
|
||||
--max-time 10 \
|
||||
--cert "$CERT_DIR/client.crt" --key "$CERT_DIR/client.key" \
|
||||
-X POST "$url" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$CAPABILITIES_JSON" || echo "000")
|
||||
if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" || "$HTTP_STATUS" == "204" ]]; then
|
||||
SUCCESS=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p /var/log/fc-signage-player
|
||||
if [[ "$SUCCESS" != "true" ]]; then
|
||||
echo "[$(date -Is)] capability declare: no endpoint accepted the profile; logging locally" \
|
||||
| tee -a /var/log/fc-signage-player/capabilities.log
|
||||
echo "$CAPABILITIES_JSON" | tee -a /var/log/fc-signage-player/capabilities.log
|
||||
else
|
||||
echo "[$(date -Is)] capability declare: ok ($url)" | tee -a /var/log/fc-signage-player/capabilities.log
|
||||
fi
|
||||
|
||||
echo "$CAPABILITIES_JSON"
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NODE_JSON="/etc/flowercore/signage-node.json"
|
||||
CERT_DIR="/etc/fc-signage-player"
|
||||
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
|
||||
SETUP_CODE_FILE="/etc/flowercore/signage-setup-code"
|
||||
|
||||
mkdir -p /etc/flowercore "$CERT_DIR" /var/log/fc-signage-player
|
||||
chown fc-signage:fc-signage /etc/flowercore "$CERT_DIR" /var/log/fc-signage-player
|
||||
chmod 0750 "$CERT_DIR"
|
||||
|
||||
if [[ -s "$NODE_JSON" && -s "$CERT_DIR/client.p12" ]]; then
|
||||
ENROLLED=$(jq -r '.enrolledAt // empty' "$NODE_JSON")
|
||||
if [[ -n "$ENROLLED" ]]; then
|
||||
echo "[$(date -Is)] bootstrap: already enrolled at $ENROLLED; skipping"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -s "$NODE_JSON" ]]; then
|
||||
NODE_UUID=$(jq -r '.nodeUuid // empty' "$NODE_JSON")
|
||||
MACHINE_ID=$(jq -r '.machineId // empty' "$NODE_JSON")
|
||||
else
|
||||
NODE_UUID=$(uuidgen)
|
||||
MACHINE_ID=$(echo "$NODE_UUID" | tr -d '-' | cut -c1-16)
|
||||
jq -n --arg uuid "$NODE_UUID" --arg machine "$MACHINE_ID" --arg host "$(hostname -f)" --arg ts "$(date -Is)" \
|
||||
'{nodeUuid: $uuid, machineId: $machine, hostname: $host, platform: "linux-arm64-pi", createdAt: $ts}' \
|
||||
> "$NODE_JSON"
|
||||
chmod 0640 "$NODE_JSON"
|
||||
chown fc-signage:fc-signage "$NODE_JSON"
|
||||
fi
|
||||
|
||||
SETUP_CODE=""
|
||||
if [[ -s "$SETUP_CODE_FILE" ]]; then
|
||||
SETUP_CODE=$(tr -d '\r\n\t ' < "$SETUP_CODE_FILE")
|
||||
fi
|
||||
|
||||
MODEL=$(tr -d '\0' < /sys/firmware/devicetree/base/model 2>/dev/null || echo Unknown)
|
||||
REG_PAYLOAD=$(jq -n \
|
||||
--arg machine "$MACHINE_ID" \
|
||||
--arg name "$(hostname -f)" \
|
||||
--arg setup "$SETUP_CODE" \
|
||||
--arg resolution "1920x1080" \
|
||||
--arg model "$MODEL" \
|
||||
'{
|
||||
machineId: $machine,
|
||||
name: $name,
|
||||
setupCode: ($setup | if . == "" then null else . end),
|
||||
resolution: $resolution,
|
||||
hardwareModel: $model,
|
||||
platform: "linux-arm64-pi"
|
||||
}')
|
||||
|
||||
for attempt in 1 2; do
|
||||
HTTP_STATUS=$(curl -sk -o /tmp/register-response.json -w "%{http_code}" \
|
||||
--max-time 15 \
|
||||
-X POST "${SIGNAGE_URL}/api/v1/nodes/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$REG_PAYLOAD" || echo "000")
|
||||
if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" ]]; then
|
||||
break
|
||||
fi
|
||||
echo "[$(date -Is)] bootstrap: register attempt $attempt returned $HTTP_STATUS" >&2
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then
|
||||
echo "[$(date -Is)] bootstrap: register failed after 2 attempts" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
NODE_ID=$(jq -r '.nodeId // empty' /tmp/register-response.json)
|
||||
if [[ -z "$NODE_ID" ]]; then
|
||||
echo "[$(date -Is)] bootstrap: register response did not include nodeId" >&2
|
||||
exit 2
|
||||
fi
|
||||
jq --arg id "$NODE_ID" '.nodeId = $id' "$NODE_JSON" > "${NODE_JSON}.tmp" && mv "${NODE_JSON}.tmp" "$NODE_JSON"
|
||||
|
||||
if [[ -s "$SETUP_CODE_FILE" ]]; then
|
||||
curl -sk -X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/approve-via-setup-code" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"setupCode\":\"${SETUP_CODE}\"}" \
|
||||
-o /dev/null || true
|
||||
fi
|
||||
|
||||
STATUS=""
|
||||
DEADLINE=$(( $(date +%s) + 1800 ))
|
||||
while (( $(date +%s) < DEADLINE )); do
|
||||
STATUS=$(curl -sk --max-time 5 "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/status" | jq -r '.status // empty')
|
||||
if [[ "$STATUS" == "Approved" || "$STATUS" == "Enrolled" || "$STATUS" == "Online" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
|
||||
if [[ "$STATUS" != "Approved" && "$STATUS" != "Enrolled" && "$STATUS" != "Online" ]]; then
|
||||
echo "[$(date -Is)] bootstrap: approval not granted within 30min budget" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
KEY_PATH="${CERT_DIR}/client.key"
|
||||
CSR_PATH="${CERT_DIR}/client.csr"
|
||||
openssl ecparam -genkey -name prime256v1 -out "$KEY_PATH"
|
||||
openssl req -new -key "$KEY_PATH" -out "$CSR_PATH" \
|
||||
-subj "/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi"
|
||||
|
||||
ENROLL_PAYLOAD=$(jq -n --arg csr "$(cat "$CSR_PATH")" '{certificateSigningRequest: $csr}')
|
||||
HTTP_STATUS=$(curl -sk -o /tmp/enroll-response.json -w "%{http_code}" \
|
||||
--max-time 15 \
|
||||
-X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/enroll" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$ENROLL_PAYLOAD")
|
||||
|
||||
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then
|
||||
echo "[$(date -Is)] bootstrap: enroll failed with HTTP $HTTP_STATUS" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
jq -r '.clientCertificatePem // .signedCertificatePem' /tmp/enroll-response.json > "${CERT_DIR}/client.crt"
|
||||
jq -r '.caCertificatePem' /tmp/enroll-response.json > "${CERT_DIR}/ca-chain.pem"
|
||||
P12_PASS=$(openssl rand -hex 24)
|
||||
echo -n "$P12_PASS" > "${CERT_DIR}/client.p12.pass"
|
||||
chmod 0600 "${CERT_DIR}/client.p12.pass"
|
||||
|
||||
openssl pkcs12 -export \
|
||||
-inkey "$KEY_PATH" \
|
||||
-in "${CERT_DIR}/client.crt" \
|
||||
-certfile "${CERT_DIR}/ca-chain.pem" \
|
||||
-out "${CERT_DIR}/client.p12" \
|
||||
-password "pass:${P12_PASS}"
|
||||
|
||||
chown fc-signage:fc-signage "${CERT_DIR}"/* "$NODE_JSON"
|
||||
chmod 0640 "${CERT_DIR}/client.p12" "${CERT_DIR}/client.crt" "${CERT_DIR}/ca-chain.pem" "$KEY_PATH"
|
||||
chmod 0600 "${CERT_DIR}/client.p12.pass"
|
||||
|
||||
EXPIRY=$(openssl x509 -in "${CERT_DIR}/client.crt" -enddate -noout | sed 's/notAfter=//')
|
||||
jq --arg ts "$(date -Is)" --arg exp "$EXPIRY" \
|
||||
'.enrolledAt = $ts | .certExpiry = $exp' "$NODE_JSON" > "${NODE_JSON}.tmp" \
|
||||
&& mv "${NODE_JSON}.tmp" "$NODE_JSON"
|
||||
|
||||
systemctl start flowercore-signage-detect-display.service || true
|
||||
systemctl start flowercore-signage-player-pi.service || true
|
||||
echo "[$(date -Is)] bootstrap: enrolled and kiosk started (NodeId=${NODE_ID})"
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sleep 2
|
||||
systemctl start flowercore-signage-detect-display.service || true
|
||||
systemctl restart flowercore-signage-player-pi.service
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NODE_JSON="/etc/flowercore/signage-node.json"
|
||||
NODE_ID=$(jq -r '.nodeId' "$NODE_JSON")
|
||||
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
|
||||
CERT_DIR="/etc/fc-signage-player"
|
||||
|
||||
CERT_THUMB=$(openssl pkcs12 -in "$CERT_DIR/client.p12" -passin file:"$CERT_DIR/client.p12.pass" -nodes -nokeys 2>/dev/null \
|
||||
| openssl x509 -fingerprint -sha256 -noout \
|
||||
| sed 's/.*=//' \
|
||||
| tr -d ':')
|
||||
|
||||
PLAYER_URL="${SIGNAGE_URL}/player/${NODE_ID}/embed?token=${CERT_THUMB}"
|
||||
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
--cert-type P12 --cert "$CERT_DIR/client.p12:$(cat "$CERT_DIR/client.p12.pass")" \
|
||||
"$PLAYER_URL" || echo "000")
|
||||
|
||||
mkdir -p /var/log/fc-signage-player
|
||||
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "301" && "$HTTP_STATUS" != "302" ]]; then
|
||||
echo "[$(date -Is)] /embed returned $HTTP_STATUS; falling back to /player/${NODE_ID}" \
|
||||
>> /var/log/fc-signage-player/url-divergence.log
|
||||
PLAYER_URL="${SIGNAGE_URL}/player/${NODE_ID}?token=${CERT_THUMB}"
|
||||
fi
|
||||
|
||||
exec chromium-browser \
|
||||
--kiosk \
|
||||
--noerrdialogs \
|
||||
--disable-infobars \
|
||||
--disable-translate \
|
||||
--disable-features=TranslateUI,InfiniteSessionRestore \
|
||||
--autoplay-policy=no-user-gesture-required \
|
||||
--password-store=basic \
|
||||
--user-data-dir=/var/lib/fc-signage-player/profile \
|
||||
--disk-cache-dir=/var/lib/fc-signage-player/cache \
|
||||
--disk-cache-size=104857600 \
|
||||
--no-first-run \
|
||||
--no-default-browser-check \
|
||||
--check-for-update-interval=2592000 \
|
||||
--enable-features=OverlayScrollbar \
|
||||
--start-fullscreen \
|
||||
--window-position=0,0 \
|
||||
--window-size=1920,1080 \
|
||||
"$PLAYER_URL"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p /var/log/fc-signage-player
|
||||
|
||||
for f in /etc/flowercore/signage-node.json /etc/fc-signage-player/client.p12 /etc/fc-signage-player/client.p12.pass; do
|
||||
if [[ ! -r "$f" ]]; then
|
||||
echo "[$(date -Is)] prelaunch: missing or unreadable $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if openssl pkcs12 -in /etc/fc-signage-player/client.p12 -passin file:/etc/fc-signage-player/client.p12.pass -nokeys -clcerts 2>/dev/null \
|
||||
| openssl x509 -checkend $((7*24*3600)) -noout; then
|
||||
:
|
||||
else
|
||||
echo "[$(date -Is)] prelaunch: client cert expires within 7 days" >&2
|
||||
fi
|
||||
|
||||
echo "[$(date -Is)] prelaunch: ok" | tee -a /var/log/fc-signage-player/prelaunch.log
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CERT_DIR="/etc/fc-signage-player"
|
||||
NODE_JSON="/etc/flowercore/signage-node.json"
|
||||
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
|
||||
|
||||
[[ -s "$CERT_DIR/client.crt" ]] || { echo "no cert to renew"; exit 0; }
|
||||
|
||||
if openssl x509 -in "$CERT_DIR/client.crt" -checkend $((30*24*3600)) -noout; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NODE_ID=$(jq -r '.nodeId' "$NODE_JSON")
|
||||
NEW_KEY="$CERT_DIR/client.key.new"
|
||||
NEW_CSR="$CERT_DIR/client.csr.new"
|
||||
|
||||
openssl ecparam -genkey -name prime256v1 -out "$NEW_KEY"
|
||||
openssl req -new -key "$NEW_KEY" -out "$NEW_CSR" \
|
||||
-subj "/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi"
|
||||
|
||||
HTTP_STATUS=$(curl -sk -o /tmp/renew-response.json -w "%{http_code}" \
|
||||
--cert "$CERT_DIR/client.crt" --key "$CERT_DIR/client.key" \
|
||||
-X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/renew" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg csr "$(cat "$NEW_CSR")" '{certificateSigningRequest: $csr}')")
|
||||
|
||||
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then
|
||||
echo "[$(date -Is)] renew: failed HTTP $HTTP_STATUS; leaving old cert in place" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
jq -r '.clientCertificatePem // .signedCertificatePem' /tmp/renew-response.json > "$CERT_DIR/client.crt.new"
|
||||
jq -r '.caCertificatePem' /tmp/renew-response.json > "$CERT_DIR/ca-chain.pem.new"
|
||||
P12_PASS=$(cat "$CERT_DIR/client.p12.pass")
|
||||
openssl pkcs12 -export -inkey "$NEW_KEY" -in "$CERT_DIR/client.crt.new" \
|
||||
-certfile "$CERT_DIR/ca-chain.pem.new" \
|
||||
-out "$CERT_DIR/client.p12.new" -password "pass:${P12_PASS}"
|
||||
|
||||
mv "$CERT_DIR/client.key.new" "$CERT_DIR/client.key"
|
||||
mv "$CERT_DIR/client.crt.new" "$CERT_DIR/client.crt"
|
||||
mv "$CERT_DIR/ca-chain.pem.new" "$CERT_DIR/ca-chain.pem"
|
||||
mv "$CERT_DIR/client.p12.new" "$CERT_DIR/client.p12"
|
||||
|
||||
chown fc-signage:fc-signage "$CERT_DIR"/client.*
|
||||
systemctl restart flowercore-signage-player-pi.service
|
||||
@@ -0,0 +1,2 @@
|
||||
# Settle DRM for 2s before restarting Chromium, then redeclare capabilities.
|
||||
SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl start flowercore-signage-player-pi-hdmi.service"
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Signage Pi: first-boot identity + mTLS enrollment
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
Before=flowercore-signage-player-pi.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/flowercore-signage-bootstrap.sh
|
||||
RemainAfterExit=yes
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
TimeoutStartSec=2100
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Signage Pi: detect connected display + declare capabilities
|
||||
After=flowercore-signage-bootstrap.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=fc-signage
|
||||
ExecStart=/usr/local/bin/fc-signage-detect-display
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Daily FlowerCore Signage Pi display capability redeclaration
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
OnBootSec=30s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Signage Pi Player HDMI hotplug responder
|
||||
DefaultDependencies=no
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/flowercore-signage-hdmi-respond.sh
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Digital Signage Pi Player (Chromium kiosk)
|
||||
Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/appletv-pi-signage-agents-design.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target graphical.target
|
||||
ConditionPathExists=/etc/flowercore/signage-node.json
|
||||
ConditionPathExists=/etc/fc-signage-player/client.p12
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=fc-signage
|
||||
Group=fc-signage
|
||||
WorkingDirectory=/var/lib/fc-signage-player
|
||||
EnvironmentFile=-/etc/flowercore/signage-player.env
|
||||
ExecStartPre=/usr/local/bin/flowercore-signage-prelaunch.sh
|
||||
ExecStart=/usr/local/bin/flowercore-signage-launch.sh
|
||||
Restart=always
|
||||
RestartSec=10s
|
||||
StartLimitBurst=5
|
||||
StartLimitIntervalSec=300s
|
||||
MemoryMax=2G
|
||||
MemoryHigh=1500M
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/fc-signage-player /var/log/fc-signage-player
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical.target
|
||||
@@ -0,0 +1,6 @@
|
||||
[Unit]
|
||||
Description=FlowerCore Signage Pi: cert renewal worker
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/flowercore-signage-renew-cert.sh
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Daily check for FlowerCore Signage Pi cert renewal
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
RandomizedDelaySec=2h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
22
apps/fc-signage-pi-player/tests/display_capability.bats
Normal file
22
apps/fc-signage-pi-player/tests/display_capability.bats
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
APP_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
DETECT="$APP_ROOT/scripts/fc-signage-detect-display"
|
||||
}
|
||||
|
||||
@test "display detection emits graceful disconnected profile when no hdmi connector is present" {
|
||||
script="$(cat "$DETECT")"
|
||||
[[ "$script" == *"displayConnected: false"* ]]
|
||||
[[ "$script" == *"No HDMI display detected"* ]]
|
||||
}
|
||||
|
||||
@test "display detection parses edid, falls back to kmsprint, and logs endpoint failures locally" {
|
||||
script="$(cat "$DETECT")"
|
||||
[[ "$script" == *"edid-decode"* ]]
|
||||
[[ "$script" == *"HDR (Static|Dynamic) Metadata Block"* ]]
|
||||
[[ "$script" == *"kmsprint"* ]]
|
||||
[[ "$script" == *"/api/v1/nodes/\${NODE_ID}/capabilities"* ]]
|
||||
[[ "$script" == *"/api/v1/displays/\${NODE_ID}/capability-profile"* ]]
|
||||
[[ "$script" == *"capabilities.log"* ]]
|
||||
}
|
||||
64
apps/fc-signage-pi-player/tests/identity_bootstrap.bats
Normal file
64
apps/fc-signage-pi-player/tests/identity_bootstrap.bats
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
APP_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
BOOTSTRAP="$APP_ROOT/scripts/flowercore-signage-bootstrap.sh"
|
||||
RENEW="$APP_ROOT/scripts/flowercore-signage-renew-cert.sh"
|
||||
}
|
||||
|
||||
@test "bootstrap is idempotent when node is already enrolled" {
|
||||
script="$(cat "$BOOTSTRAP")"
|
||||
[[ "$script" == *'[[ -s "$NODE_JSON" && -s "$CERT_DIR/client.p12" ]]'* ]]
|
||||
[[ "$script" == *"already enrolled"* ]]
|
||||
[[ "$script" == *"exit 0"* ]]
|
||||
}
|
||||
|
||||
@test "bootstrap generates a stable node uuid and machine id" {
|
||||
script="$(cat "$BOOTSTRAP")"
|
||||
[[ "$script" == *"uuidgen"* ]]
|
||||
[[ "$script" == *"nodeUuid"* ]]
|
||||
[[ "$script" == *"machineId"* ]]
|
||||
[[ "$script" == *"cut -c1-16"* ]]
|
||||
}
|
||||
|
||||
@test "bootstrap posts to the canonical register endpoint" {
|
||||
grep -q '/api/v1/nodes/register' "$BOOTSTRAP"
|
||||
grep -q '"linux-arm64-pi"' "$BOOTSTRAP"
|
||||
}
|
||||
|
||||
@test "bootstrap retries registration once for first-call races" {
|
||||
script="$(cat "$BOOTSTRAP")"
|
||||
[[ "$script" == *"for attempt in 1 2"* ]]
|
||||
[[ "$script" == *"register attempt \$attempt returned"* ]]
|
||||
[[ "$script" == *"sleep 5"* ]]
|
||||
}
|
||||
|
||||
@test "bootstrap supports setup-code approval with manual polling fallback" {
|
||||
script="$(cat "$BOOTSTRAP")"
|
||||
[[ "$script" == *"signage-setup-code"* ]]
|
||||
[[ "$script" == *"approve-via-setup-code"* ]]
|
||||
[[ "$script" == *"+ 1800"* ]]
|
||||
[[ "$script" == *"sleep 15"* ]]
|
||||
}
|
||||
|
||||
@test "bootstrap generates an ecdsa p256 csr for the signage pi subject" {
|
||||
script="$(cat "$BOOTSTRAP")"
|
||||
[[ "$script" == *"ecparam -genkey -name prime256v1"* ]]
|
||||
[[ "$script" == *'/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi'* ]]
|
||||
}
|
||||
|
||||
@test "bootstrap writes pkcs12 bundle with restrictive permissions" {
|
||||
script="$(cat "$BOOTSTRAP")"
|
||||
[[ "$script" == *"openssl pkcs12 -export"* ]]
|
||||
[[ "$script" == *"client.p12.pass"* ]]
|
||||
[[ "$script" == *"chmod 0640"* ]]
|
||||
[[ "$script" == *"chmod 0600"* ]]
|
||||
}
|
||||
|
||||
@test "renewal only calls renew endpoint inside the thirty-day window and swaps atomically" {
|
||||
script="$(cat "$RENEW")"
|
||||
[[ "$script" == *'-checkend $((30*24*3600))'* ]]
|
||||
[[ "$script" == *"/api/v1/nodes/\${NODE_ID}/renew"* ]]
|
||||
[[ "$script" == *"client.key.new"* ]]
|
||||
[[ "$script" == *'mv "$CERT_DIR/client.p12.new" "$CERT_DIR/client.p12"'* ]]
|
||||
}
|
||||
68
apps/fc-signage-pi-player/tests/systemd_kiosk_wrapper.bats
Normal file
68
apps/fc-signage-pi-player/tests/systemd_kiosk_wrapper.bats
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
setup() {
|
||||
APP_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
|
||||
}
|
||||
|
||||
@test "player unit exists" {
|
||||
[ -f "$APP_ROOT/systemd/flowercore-signage-player-pi.service" ]
|
||||
}
|
||||
|
||||
@test "player unit uses simple chromium service with restart backoff" {
|
||||
unit="$(cat "$APP_ROOT/systemd/flowercore-signage-player-pi.service")"
|
||||
[[ "$unit" == *"Type=simple"* ]]
|
||||
[[ "$unit" == *"Restart=always"* ]]
|
||||
[[ "$unit" == *"RestartSec=10s"* ]]
|
||||
[[ "$unit" == *"StartLimitBurst=5"* ]]
|
||||
[[ "$unit" == *"StartLimitIntervalSec=300s"* ]]
|
||||
}
|
||||
|
||||
@test "player unit caps chromium memory at two gigabytes" {
|
||||
grep -q '^MemoryMax=2G$' "$APP_ROOT/systemd/flowercore-signage-player-pi.service"
|
||||
grep -q '^MemoryHigh=1500M$' "$APP_ROOT/systemd/flowercore-signage-player-pi.service"
|
||||
}
|
||||
|
||||
@test "player unit condition-gates startup on identity and p12 certificate" {
|
||||
grep -q '^ConditionPathExists=/etc/flowercore/signage-node.json$' "$APP_ROOT/systemd/flowercore-signage-player-pi.service"
|
||||
grep -q '^ConditionPathExists=/etc/fc-signage-player/client.p12$' "$APP_ROOT/systemd/flowercore-signage-player-pi.service"
|
||||
}
|
||||
|
||||
@test "player unit runs prelaunch checks before chromium" {
|
||||
grep -q '^ExecStartPre=/usr/local/bin/flowercore-signage-prelaunch.sh$' "$APP_ROOT/systemd/flowercore-signage-player-pi.service"
|
||||
grep -q '^ExecStart=/usr/local/bin/flowercore-signage-launch.sh$' "$APP_ROOT/systemd/flowercore-signage-player-pi.service"
|
||||
}
|
||||
|
||||
@test "hdmi udev rule routes through the two-second settle service" {
|
||||
rule="$(cat "$APP_ROOT/systemd/99-flowercore-signage-hdmi.rules")"
|
||||
[[ "$rule" == *'KERNEL=="card?-HDMI-A-?"'* ]]
|
||||
[[ "$rule" == *"systemctl start flowercore-signage-player-pi-hdmi.service"* ]]
|
||||
[[ "$rule" != *"systemctl restart flowercore-signage-player-pi.service"* ]]
|
||||
}
|
||||
|
||||
@test "hdmi responder settles, declares display, then restarts chromium" {
|
||||
responder="$(cat "$APP_ROOT/scripts/flowercore-signage-hdmi-respond.sh")"
|
||||
[[ "$responder" == *"sleep 2"* ]]
|
||||
[[ "$responder" == *"systemctl start flowercore-signage-detect-display.service"* ]]
|
||||
[[ "$responder" == *"systemctl restart flowercore-signage-player-pi.service"* ]]
|
||||
}
|
||||
|
||||
@test "chromium policy json is valid and disables credential prompts" {
|
||||
command -v jq >/dev/null || skip "jq not installed"
|
||||
jq -e '.AutofillAddressEnabled == false and .AutofillCreditCardEnabled == false and .PasswordManagerEnabled == false' \
|
||||
"$APP_ROOT/chromium-policies/flowercore-signage.json" >/dev/null
|
||||
}
|
||||
|
||||
@test "launch script tries embed URL and logs bare-player fallback" {
|
||||
launch="$(cat "$APP_ROOT/scripts/flowercore-signage-launch.sh")"
|
||||
[[ "$launch" == *'/player/${NODE_ID}/embed?token=${CERT_THUMB}'* ]]
|
||||
[[ "$launch" == *"url-divergence.log"* ]]
|
||||
[[ "$launch" == *'/player/${NODE_ID}?token=${CERT_THUMB}'* ]]
|
||||
}
|
||||
|
||||
@test "prelaunch script validates required node and cert files" {
|
||||
prelaunch="$(cat "$APP_ROOT/scripts/flowercore-signage-prelaunch.sh")"
|
||||
[[ "$prelaunch" == *"/etc/flowercore/signage-node.json"* ]]
|
||||
[[ "$prelaunch" == *"/etc/fc-signage-player/client.p12"* ]]
|
||||
[[ "$prelaunch" == *"/etc/fc-signage-player/client.p12.pass"* ]]
|
||||
[[ "$prelaunch" == *"exit 1"* ]]
|
||||
}
|
||||
@@ -76,15 +76,13 @@ spec:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
|
||||
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"]
|
||||
397
apps/fc-ttsreader/biblical-tts/app.py
Normal file
397
apps/fc-ttsreader/biblical-tts/app.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""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
|
||||
import unicodedata
|
||||
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
|
||||
|
||||
|
||||
HEBREW_CHAR_RE = re.compile(r"[\u0590-\u05FF]")
|
||||
HEBREW_WORD_RE = re.compile(r"[\u0590-\u05FF]+")
|
||||
|
||||
# eSpeak-NG's Hebrew voice can spell unpointed Hebrew as Unicode character
|
||||
# names on some builds. For source-text study reads, prefer a stable
|
||||
# scholarly transliteration so words sound like words even without niqqud.
|
||||
HEBREW_WORD_TRANSLITERATIONS = {
|
||||
"אב": "av",
|
||||
"אבא": "abba",
|
||||
"אברהם": "Avraham",
|
||||
"אדמה": "adamah",
|
||||
"אדני": "Adonai",
|
||||
"אדם": "adam",
|
||||
"אור": "or",
|
||||
"אלהים": "Elohim",
|
||||
"אלוהים": "Elohim",
|
||||
"אמן": "amen",
|
||||
"אם": "em",
|
||||
"אמת": "emet",
|
||||
"ארץ": "eretz",
|
||||
"אש": "esh",
|
||||
"את": "et",
|
||||
"בית": "beit",
|
||||
"בן": "ben",
|
||||
"ברא": "bara",
|
||||
"בראשית": "bereshit",
|
||||
"ברית": "berit",
|
||||
"ברוך": "barukh",
|
||||
"בת": "bat",
|
||||
"גוי": "goy",
|
||||
"גוים": "goyim",
|
||||
"גויים": "goyim",
|
||||
"דבר": "davar",
|
||||
"דברים": "devarim",
|
||||
"דוד": "David",
|
||||
"הלל": "hallel",
|
||||
"הארץ": "ha-aretz",
|
||||
"הברית": "ha-berit",
|
||||
"החדשה": "ha-chadashah",
|
||||
"השמים": "ha-shamayim",
|
||||
"השמיים": "ha-shamayim",
|
||||
"ויאמר": "vayomer",
|
||||
"יהוה": "Adonai",
|
||||
"יוסף": "Yosef",
|
||||
"יוחנן": "Yochanan",
|
||||
"ישראל": "Yisrael",
|
||||
"ישוע": "Yeshua",
|
||||
"יצחק": "Yitzchak",
|
||||
"יעקב": "Yaakov",
|
||||
"ירושלים": "Yerushalayim",
|
||||
"כהן": "kohen",
|
||||
"כהנים": "kohanim",
|
||||
"מים": "mayim",
|
||||
"מות": "mavet",
|
||||
"מושיע": "moshia",
|
||||
"מלך": "melekh",
|
||||
"מלכות": "malkhut",
|
||||
"מרים": "Miriam",
|
||||
"משה": "Moshe",
|
||||
"משיח": "Mashiach",
|
||||
"נביא": "navi",
|
||||
"נביאים": "neviim",
|
||||
"עם": "am",
|
||||
"עולם": "olam",
|
||||
"צדק": "tzedek",
|
||||
"קדוש": "qadosh",
|
||||
"קדושים": "qedoshim",
|
||||
"קול": "qol",
|
||||
"רוח": "ruach",
|
||||
"שאול": "Shaul",
|
||||
"שמים": "shamayim",
|
||||
"שמיים": "shamayim",
|
||||
"שמעון": "Shimon",
|
||||
"שלום": "Shalom",
|
||||
"תורה": "torah",
|
||||
"חכמה": "chokhmah",
|
||||
"חסד": "chesed",
|
||||
"חיים": "chayim",
|
||||
"חושך": "choshekh",
|
||||
}
|
||||
|
||||
HEBREW_LETTERS = {
|
||||
"א": "a",
|
||||
"ב": "b",
|
||||
"ג": "g",
|
||||
"ד": "d",
|
||||
"ה": "h",
|
||||
"ו": "v",
|
||||
"ז": "z",
|
||||
"ח": "kh",
|
||||
"ט": "t",
|
||||
"י": "y",
|
||||
"כ": "kh",
|
||||
"ך": "kh",
|
||||
"ל": "l",
|
||||
"מ": "m",
|
||||
"ם": "m",
|
||||
"נ": "n",
|
||||
"ן": "n",
|
||||
"ס": "s",
|
||||
"ע": "a",
|
||||
"פ": "p",
|
||||
"ף": "f",
|
||||
"צ": "ts",
|
||||
"ץ": "ts",
|
||||
"ק": "q",
|
||||
"ר": "r",
|
||||
"ש": "sh",
|
||||
"ת": "t",
|
||||
}
|
||||
|
||||
HEBREW_VOWELISH = {"a", "e", "i", "o", "u"}
|
||||
|
||||
|
||||
def _strip_hebrew_marks(value: str) -> str:
|
||||
decomposed = unicodedata.normalize("NFD", value)
|
||||
return "".join(
|
||||
ch for ch in decomposed
|
||||
if unicodedata.category(ch) != "Mn" and ch not in {"׳", "״", "־"}
|
||||
)
|
||||
|
||||
|
||||
def _fallback_hebrew_transliteration(word: str) -> str:
|
||||
tokens: list[str] = []
|
||||
chars = list(word)
|
||||
for index, ch in enumerate(chars):
|
||||
token = HEBREW_LETTERS.get(ch)
|
||||
if token is None:
|
||||
continue
|
||||
if ch == "ה" and index == len(chars) - 1:
|
||||
token = "ah"
|
||||
elif ch == "י" and index > 0:
|
||||
token = "i"
|
||||
elif ch == "ו" and index > 0:
|
||||
token = "o"
|
||||
tokens.append(token)
|
||||
|
||||
if not tokens:
|
||||
return word
|
||||
|
||||
spoken: list[str] = []
|
||||
for index, token in enumerate(tokens):
|
||||
spoken.append(token)
|
||||
next_token = tokens[index + 1] if index + 1 < len(tokens) else ""
|
||||
if (
|
||||
token[-1:] not in HEBREW_VOWELISH
|
||||
and next_token
|
||||
and next_token[:1] not in HEBREW_VOWELISH
|
||||
):
|
||||
spoken.append("a")
|
||||
return "".join(spoken)
|
||||
|
||||
|
||||
def _transliterate_hebrew_word(match: re.Match[str]) -> str:
|
||||
original = match.group(0)
|
||||
normalized = _strip_hebrew_marks(original)
|
||||
if not normalized:
|
||||
return original
|
||||
|
||||
direct = HEBREW_WORD_TRANSLITERATIONS.get(normalized)
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
if normalized.startswith("ו") and len(normalized) > 1:
|
||||
rest = HEBREW_WORD_TRANSLITERATIONS.get(normalized[1:])
|
||||
if rest:
|
||||
return f"ve-{rest}"
|
||||
|
||||
if normalized.startswith("ה") and len(normalized) > 1:
|
||||
rest = HEBREW_WORD_TRANSLITERATIONS.get(normalized[1:])
|
||||
if rest:
|
||||
return f"ha-{rest}"
|
||||
|
||||
return _fallback_hebrew_transliteration(normalized)
|
||||
|
||||
|
||||
def _prepare_synthesis_input(text: str, language: str, voice: str) -> tuple[str, str]:
|
||||
if language.lower().startswith("he") and HEBREW_CHAR_RE.search(text):
|
||||
spoken = HEBREW_WORD_RE.sub(_transliterate_hebrew_word, text)
|
||||
return spoken, "en-us"
|
||||
return text, voice
|
||||
|
||||
|
||||
def _resolve_voice(req: TtsRequest) -> str:
|
||||
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)
|
||||
spoken_text, synth_voice = _prepare_synthesis_input(req.text, req.language, voice)
|
||||
args = [
|
||||
"--stdout",
|
||||
"-v", synth_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, spoken_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, spoken_text: str) -> int:
|
||||
args = ["--pho", "--quiet", "-v", voice, "-s", str(req.rate)]
|
||||
out = _run_espeak(args, spoken_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)
|
||||
spoken_text, synth_voice = _prepare_synthesis_input(req.text, req.language, voice)
|
||||
total_ms = _estimate_total_ms(req, synth_voice, spoken_text)
|
||||
|
||||
# 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": synth_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
|
||||
@@ -37,6 +37,19 @@ spec:
|
||||
app.kubernetes.io/name: ttsreader-piper
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
# Bypass CoreDNS's *.iamworkin.lan wildcard so the init container reaches
|
||||
# huggingface.co directly when it seeds voice models.
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers:
|
||||
- 10.43.0.10
|
||||
searches:
|
||||
- fc-ttsreader.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- name: ndots
|
||||
value: "2"
|
||||
initContainers:
|
||||
- name: seed-voices
|
||||
image: rhasspy/wyoming-piper:latest
|
||||
@@ -97,13 +110,19 @@ spec:
|
||||
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: 256Mi
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
cpu: 2000m
|
||||
memory: 3Gi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
@@ -112,6 +131,377 @@ spec:
|
||||
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:v20260506-hebrew-translit
|
||||
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:
|
||||
@@ -142,7 +532,7 @@ spec:
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: web
|
||||
image: localhost/fc-ttsreader-web:v202604232310
|
||||
image: localhost/fc-ttsreader-web:v20260603-s54cx14-pr29-schema
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 5217
|
||||
@@ -160,12 +550,57 @@ spec:
|
||||
value: "/usr/bin/ffmpeg"
|
||||
- name: TtsReader__Bible__CorpusRoot
|
||||
value: "/data/corpus-cache/world-english-bible/eng/usx"
|
||||
- name: TtsReader__ChapterContext__DatabasePath
|
||||
value: "/data/chapter-context.db"
|
||||
- name: TtsReader__Jobs__Root
|
||||
value: "/data/jobs"
|
||||
- name: TtsReader__Export__LocalCasRoot
|
||||
value: "/data/bundles/cas"
|
||||
- name: TtsReader__Piper__Host
|
||||
value: "ttsreader-piper.fc-ttsreader.svc.cluster.local."
|
||||
value: "10.0.57.17"
|
||||
- name: TtsReader__Piper__Port
|
||||
value: "10200"
|
||||
value: "8500"
|
||||
- name: TtsReader__Piper__Transport
|
||||
value: "http"
|
||||
- name: TtsReader__Piper__HttpPath
|
||||
value: "/tts"
|
||||
- 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: FlowerCore__Tts__BiblicalTts__Enabled
|
||||
value: "true"
|
||||
- name: FlowerCore__Tts__BiblicalTts__BaseUrl
|
||||
value: "http://ttsreader-biblical.fc-ttsreader.svc.cluster.local.:10402"
|
||||
- name: FlowerCore__Tts__BiblicalTts__TimeoutSeconds
|
||||
value: "60"
|
||||
- name: FlowerCore__Tts__BiblicalTts__DefaultLanguage
|
||||
value: "grc"
|
||||
- name: Speech__Alignment__Enabled
|
||||
# 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
|
||||
@@ -176,6 +611,21 @@ spec:
|
||||
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"
|
||||
- name: TtsReader__VoiceLibrary__ReferenceClip__Directory
|
||||
value: "/data/voice-reference-clips"
|
||||
# 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:
|
||||
@@ -190,7 +640,10 @@ spec:
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
# The cluster is currently saturated on requested CPU by
|
||||
# remotedesktop workloads even when real usage is low.
|
||||
# Keep the web frontend schedulable under that pressure.
|
||||
cpu: 10m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
|
||||
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
|
||||
47
apps/fc-updater/README.md
Normal file
47
apps/fc-updater/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# fc-updater — Update Center GitOps adoption
|
||||
|
||||
**Status:** adopted into `bluejay-infra` on 2026-05-06. The live ArgoCD
|
||||
Application is `infra-fc-updater`, generated by the `bluejay-infra`
|
||||
ApplicationSet with automated sync, `prune: true`, and `selfHeal: true`.
|
||||
|
||||
## Managed manifest set
|
||||
|
||||
`apps/fc-updater/fc-updater.yaml` manages:
|
||||
|
||||
- `Namespace/fc-updater`
|
||||
- `PersistentVolumeClaim/updatecenter-data`
|
||||
- `Deployment/updatecenter-web`
|
||||
- `Service/updatecenter-web`
|
||||
- `Certificate/updatecenter-web-tls`
|
||||
- `Certificate/updatecenter-web-internal-tls`
|
||||
- `IngressRoute/updatecenter-web`
|
||||
- `IngressRoute/updatecenter-web-internal`
|
||||
- `IngressRoute/updatecenter-web-public`
|
||||
|
||||
The Deployment intentionally sets `revisionHistoryLimit: 3` and
|
||||
`strategy.type: Recreate`. The service is singleton + SQLite/local bundle
|
||||
storage on `PersistentVolumeClaim/updatecenter-data`, pinned to
|
||||
`rke2-server`.
|
||||
|
||||
## Runtime dependencies intentionally not stored here
|
||||
|
||||
These live Secrets are pre-existing runtime material and are not committed to
|
||||
Git:
|
||||
|
||||
- `updater-bootstrap-auth`
|
||||
- `updater-signing`
|
||||
- `updater-webhooks`
|
||||
- `cf-origin-flowercore-io`
|
||||
|
||||
Rotate the Cloudflare Origin Certificate through
|
||||
`FlowerCore.Notes/docs/standards/code-signing-rotation-runbook.md`; the
|
||||
shared origin cert must exist in every namespace that serves a
|
||||
`*.flowercore.io` public IngressRoute.
|
||||
|
||||
## Verification
|
||||
|
||||
```powershell
|
||||
kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n argocd get application infra-fc-updater
|
||||
kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n fc-updater get deploy,svc,ingressroute,certificate,pvc
|
||||
curl.exe -sk https://update.flowercore.io/api/v1/manifests/_schema
|
||||
```
|
||||
271
apps/fc-updater/fc-updater.yaml
Normal file
271
apps/fc-updater/fc-updater.yaml
Normal file
@@ -0,0 +1,271 @@
|
||||
# FlowerCore Update Center
|
||||
# GitOps adoption of the live fc-updater namespace after PUB-1/PUB-3.
|
||||
# Runtime credentials remain in existing K8s Secrets; do not store them here.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: fc-updater
|
||||
labels:
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: updatecenter-data
|
||||
namespace: fc-updater
|
||||
labels:
|
||||
app.kubernetes.io/name: updatecenter-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
volumeMode: Filesystem
|
||||
resources:
|
||||
requests:
|
||||
# Sized for fleet bundle storage (LocalFsBundleStore.MaxTotalBytes
|
||||
# soft cap at 25 GiB per project_uc_remaining_4_apps_signed_2026_05_06).
|
||||
# Mike Bundle alone is ~5.1 GiB; cluster live capacity is already
|
||||
# 20 GiB after a manual expand. PVCs cannot shrink, so git must track
|
||||
# at least the live size to avoid the OutOfSync loop.
|
||||
storage: 25Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: updatecenter-web
|
||||
namespace: fc-updater
|
||||
labels:
|
||||
app: updatecenter-web
|
||||
app.kubernetes.io/name: updatecenter-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
# SQLite + local bundle storage live on a single RWO PVC. Recreate avoids
|
||||
# two pods overlapping the same write path during future image bumps.
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: updatecenter-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: updatecenter-web
|
||||
spec:
|
||||
nodeName: rke2-server
|
||||
containers:
|
||||
- name: web
|
||||
image: localhost/fc-updater-web:v202605310029-7974fc4
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
env:
|
||||
- name: ASPNETCORE_URLS
|
||||
value: http://+:8080
|
||||
- name: FlowerCore__Updater__Database__Provider
|
||||
value: sqlite
|
||||
- name: FlowerCore__Updater__Database__ConnectionString
|
||||
value: Data Source=/data/updatecenter.db
|
||||
- name: FlowerCore__Updater__BundleStorage__LocalFs__RootDirectory
|
||||
value: /data/bundles
|
||||
- name: FlowerCore__Updater__PublicShares__RequirePublicVisibilityOnPublicHosts
|
||||
value: "true"
|
||||
- name: FlowerCore__Updater__PublicShares__Links__0__Code
|
||||
value: 8f3c2a9e7d41
|
||||
- name: FlowerCore__Updater__PublicShares__Links__0__AppId
|
||||
value: flowercore.faith-ai-mike
|
||||
- name: FlowerCore__Updater__PublicShares__Links__0__Channel
|
||||
value: stable
|
||||
- name: FlowerCore__Updater__PublicShares__Links__0__RuntimeId
|
||||
value: win-x64
|
||||
- name: FlowerCore__Updater__PublicShares__Links__0__DisplayName
|
||||
value: Faith AI Mike Edition
|
||||
- name: FlowerCore__Updater__PublicShares__Links__0__Headline
|
||||
value: Faith AI Mike Edition
|
||||
- name: FlowerCore__Updater__PublicShares__Links__0__Description
|
||||
value: Private release link for Mike's Faith AI bundle.
|
||||
- name: FlowerCore__Audit__Sinks__Loki__Enabled
|
||||
value: "false"
|
||||
- name: FlowerCore__Updater__Auth__Bootstrap__Enabled
|
||||
value: "true"
|
||||
- name: FlowerCore__Updater__Auth__Bootstrap__Username
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-bootstrap-auth
|
||||
key: username
|
||||
- name: FlowerCore__Updater__Auth__Bootstrap__Password
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-bootstrap-auth
|
||||
key: password
|
||||
- name: FlowerCore__Updater__Auth__Bootstrap__SigningKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-bootstrap-auth
|
||||
key: signing-key
|
||||
- name: FlowerCore__Updater__Signing__AutoSignOnPublish
|
||||
value: "true"
|
||||
- name: FlowerCore__Updater__Signing__RequireSignatureOnPublish
|
||||
value: "true"
|
||||
- name: FlowerCore__Updater__Signing__PfxBase64
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-signing
|
||||
key: pfx-base64
|
||||
- name: FlowerCore__Updater__Signing__PfxPassword
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-signing
|
||||
key: pfx-password
|
||||
- name: FlowerCore__Updater__Signing__OpItemReference
|
||||
value: op://FlowerCore/step-ca-codesign
|
||||
- name: FlowerCore__Updater__Signing__TrustAnchorPath
|
||||
value: /etc/flowercore-updater/signing/root-ca.pem
|
||||
- name: FlowerCore__Updater__GitHub__Token
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-webhooks
|
||||
key: github-token
|
||||
- name: FlowerCore__Updater__GitHub__WebhookSecret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-webhooks
|
||||
key: github-webhook-secret
|
||||
- name: FlowerCore__Updater__Gitea__Token
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-webhooks
|
||||
key: gitea-token
|
||||
- name: FlowerCore__Updater__Gitea__WebhookSecret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: updater-webhooks
|
||||
key: gitea-webhook-secret
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: signing
|
||||
mountPath: /etc/flowercore-updater/signing
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: updatecenter-data
|
||||
- name: signing
|
||||
secret:
|
||||
secretName: updater-signing
|
||||
items:
|
||||
- key: root-ca.pem
|
||||
path: root-ca.pem
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: updatecenter-web
|
||||
namespace: fc-updater
|
||||
labels:
|
||||
app: updatecenter-web
|
||||
app.kubernetes.io/name: updatecenter-web
|
||||
app.kubernetes.io/part-of: flowercore
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: updatecenter-web
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: updatecenter-web-tls
|
||||
namespace: fc-updater
|
||||
spec:
|
||||
secretName: updatecenter-web-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- updatecenter.iamworkin.lan
|
||||
- updates.iamworkin.lan
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: updatecenter-web-internal-tls
|
||||
namespace: fc-updater
|
||||
spec:
|
||||
secretName: updatecenter-web-internal-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- updatecenter-internal.iamworkin.lan
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: updatecenter-web
|
||||
namespace: fc-updater
|
||||
spec:
|
||||
entryPoints:
|
||||
- web
|
||||
- websecure
|
||||
routes:
|
||||
- match: (Host(`updatecenter.iamworkin.lan`) || Host(`updates.iamworkin.lan`)) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
|
||||
kind: Rule
|
||||
services:
|
||||
- name: updatecenter-web
|
||||
port: 8080
|
||||
tls:
|
||||
secretName: updatecenter-web-tls
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: updatecenter-web-internal
|
||||
namespace: fc-updater
|
||||
spec:
|
||||
entryPoints:
|
||||
- web
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`updatecenter-internal.iamworkin.lan`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: updatecenter-web
|
||||
port: 8080
|
||||
tls:
|
||||
secretName: updatecenter-web-internal-tls
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: updatecenter-web-public
|
||||
namespace: fc-updater
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: (Host(`update.flowercore.io`) || Host(`updates.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
|
||||
kind: Rule
|
||||
services:
|
||||
- name: updatecenter-web
|
||||
port: 8080
|
||||
tls:
|
||||
secretName: cf-origin-flowercore-io
|
||||
7
apps/fc-updater/kustomization.yaml
Normal file
7
apps/fc-updater/kustomization.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# ArgoCD's bluejay-infra ApplicationSet uses a directory generator and does
|
||||
# not require kustomization.yaml. Keep this anyway as the manifest inventory
|
||||
# and for local `kubectl kustomize apps/fc-updater` previews.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- fc-updater.yaml
|
||||
@@ -1,6 +1,11 @@
|
||||
# FlowerCore Tenant — flowercore.io (main brand)
|
||||
# Public-facing placeholder landing page served by nginx
|
||||
# ArgoCD managed - BlueJay Lab
|
||||
# FlowerCore Tenant — retired flowercore.io placeholder.
|
||||
#
|
||||
# Public flowercore.io/www.flowercore.io routing is now owned by
|
||||
# apps/fc-landing/fc-landing.yaml. This tenant placeholder remains available
|
||||
# only as an in-cluster service; do not create a duplicate public
|
||||
# IngressRoute here because it competes with fc-landing and requires a
|
||||
# namespace-local cf-origin-flowercore-io Secret.
|
||||
# ArgoCD managed - BlueJay Lab
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
@@ -10,15 +15,9 @@ metadata:
|
||||
app.kubernetes.io/part-of: bluejay-infra
|
||||
flowercore.io/tenant: flowercore
|
||||
---
|
||||
# NOTE: The existing cf-origin-flowercore-io secret (covering *.flowercore.io)
|
||||
# must be copied into this namespace. It already exists in other namespaces.
|
||||
# Copy with: kubectl get secret cf-origin-flowercore-io -n fc-system -o yaml \
|
||||
# | sed 's/namespace: .*/namespace: tenant-flowercore/' \
|
||||
# | kubectl apply -f -
|
||||
---
|
||||
# Landing page HTML
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
# Landing page HTML
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: flowercore-web-html
|
||||
namespace: tenant-flowercore
|
||||
@@ -308,25 +307,6 @@ spec:
|
||||
selector:
|
||||
app: flowercore-web
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
name: http
|
||||
---
|
||||
# Traefik IngressRoute — public via Cloudflare
|
||||
# Uses existing cf-origin-flowercore-io cert (must be copied to this namespace)
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: flowercore-web
|
||||
namespace: tenant-flowercore
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: flowercore-web
|
||||
port: 80
|
||||
tls:
|
||||
secretName: cf-origin-flowercore-io
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
name: http
|
||||
|
||||
2
apps/github-runner/.gitattributes
vendored
Normal file
2
apps/github-runner/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
54
apps/github-runner/Dockerfile
Normal file
54
apps/github-runner/Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
FROM myoung34/github-runner:latest
|
||||
|
||||
ARG RUBY_VERSION=3.3.11
|
||||
ARG RUBY_MINOR=3.3
|
||||
ARG RUBY_BUILD_VERSION=v20260326
|
||||
ARG RUNNER_UID=1001
|
||||
ARG RUNNER_GID=1001
|
||||
|
||||
ENV RUNNER_TOOL_CACHE=/home/runner/_tool
|
||||
ENV RUNNER_RUBY_TOOLCACHE=/opt/runner-toolcache
|
||||
ENV PATH="/home/runner/_tool/Ruby/${RUBY_MINOR}/x64/bin:/opt/runner-toolcache/Ruby/${RUBY_MINOR}/x64/bin:${PATH}"
|
||||
|
||||
USER root
|
||||
|
||||
# Bake the IAmWorkin step-ca root CA into the system trust store. Without
|
||||
# this, .NET HttpClient calls from CI tests against *.iamworkin.lan
|
||||
# (e.g. https://selenium.iamworkin.lan/session) fail with `PartialChain`
|
||||
# because the runner image's default Ubuntu trust bundle doesn't include
|
||||
# our internal Root CA. update-ca-certificates regenerates
|
||||
# /etc/ssl/certs/ca-certificates.crt, which OpenSSL + .NET on Linux read
|
||||
# automatically — no SSL_CERT_FILE env var needed.
|
||||
COPY step-ca-root.crt /usr/local/share/ca-certificates/iamworkin-step-ca-root.crt
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
autoconf \
|
||||
bison \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libdb-dev \
|
||||
libffi-dev \
|
||||
libgdbm-dev \
|
||||
libgmp-dev \
|
||||
libncurses-dev \
|
||||
libreadline-dev \
|
||||
libssl-dev \
|
||||
libyaml-dev \
|
||||
patch \
|
||||
pkg-config \
|
||||
uuid-dev \
|
||||
zlib1g-dev \
|
||||
&& update-ca-certificates \
|
||||
&& curl -fsSL "https://github.com/rbenv/ruby-build/archive/refs/tags/${RUBY_BUILD_VERSION}.tar.gz" -o /tmp/ruby-build.tar.gz \
|
||||
&& mkdir -p /tmp/ruby-build \
|
||||
&& tar -xzf /tmp/ruby-build.tar.gz --strip-components=1 -C /tmp/ruby-build \
|
||||
&& /tmp/ruby-build/install.sh \
|
||||
&& rm -rf /tmp/ruby-build /tmp/ruby-build.tar.gz /var/lib/apt/lists/*
|
||||
|
||||
COPY install-ruby-toolcache.sh /usr/local/bin/install-ruby-toolcache.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/install-ruby-toolcache.sh \
|
||||
&& RUBY_VERSION="${RUBY_VERSION}" RUBY_MINOR="${RUBY_MINOR}" TOOLCACHE_ROOT="${RUNNER_RUBY_TOOLCACHE}" RUNNER_UID="${RUNNER_UID}" RUNNER_GID="${RUNNER_GID}" /usr/local/bin/install-ruby-toolcache.sh \
|
||||
&& ruby -v
|
||||
133
apps/github-runner/README.md
Normal file
133
apps/github-runner/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# GitHub Runner Fleet
|
||||
|
||||
ArgoCD owns `apps/github-runner/github-runner.yaml`. Do not patch live runner
|
||||
Deployments with `kubectl`; update this manifest and let ArgoCD reconcile.
|
||||
|
||||
## Runner Shape
|
||||
|
||||
All repo-scoped Linux runners use:
|
||||
|
||||
- `localhost/fc-github-runner:v20260525-ruby3.3.11-stepca`, derived from
|
||||
`myoung34/github-runner:latest`
|
||||
- `ACCESS_TOKEN` from the `github-runner-token` Secret
|
||||
- `RUN_AS_ROOT=false`
|
||||
- `EPHEMERAL=true`
|
||||
- `LABELS=self-hosted,linux,fc-build-linux`
|
||||
- writable non-root paths under `/home/runner` for .NET, NuGet, XDG cache, and
|
||||
Actions tool cache
|
||||
- Ruby 3.3.11 seeded into `/home/runner/_tool/Ruby/3.3/x64` from the baked
|
||||
`/opt/runner-toolcache` copy so `ruby/setup-ruby@v1` can discover it on
|
||||
self-hosted `ubuntu-20.04-x64` runners
|
||||
|
||||
`github-runner` for `FlowerCore.Common` is single-replica because it retains the
|
||||
original Longhorn ReadWriteOnce NuGet PVC. Every other repo-scoped runner uses
|
||||
two replicas with per-pod `emptyDir` caches. That is the safe backlog-drain
|
||||
strategy: no two pods share one RWO PVC.
|
||||
|
||||
Sprint 32 final long-tail wave adds 16 two-replica Deployments:
|
||||
`FlowerCore.Knowledge`, `FlowerCore.LlmBridge`, `FlowerCore.Media`,
|
||||
`FlowerCore.Presentations`, `FlowerCore.RemoteDesktop`, `FlowerCore.DNS`,
|
||||
`FlowerCore.Distribution`, `FlowerCore.Scoreboard`,
|
||||
`FlowerCore.SegmentDisplay`, `FlowerCore.Signage.Contracts`,
|
||||
`FlowerCore.SignalControl`, `FlowerCore.Intranet.Web`,
|
||||
`FlowerCore.Provisioning`, `FlowerCore.Redis`, `FlowerCore.MessageBoard`, and
|
||||
`FlowerCore.MenuBoard`.
|
||||
|
||||
## Image Build
|
||||
|
||||
Ruby is baked with a pinned `ruby-build` release and Ruby patch version. The pod
|
||||
still mounts an `emptyDir` over `/home/runner`, so the `setup-runner-home` init
|
||||
container copies the baked toolcache from `/opt/runner-toolcache/Ruby` into
|
||||
`/home/runner/_tool/Ruby` before the runner container starts.
|
||||
|
||||
The IAmWorkin step-ca root CA is also baked into the system trust store
|
||||
(`/usr/local/share/ca-certificates/iamworkin-step-ca-root.crt`, registered by
|
||||
`update-ca-certificates`). Without it, .NET HttpClient calls from CI tests
|
||||
against `*.iamworkin.lan` (e.g. `https://selenium.iamworkin.lan/session`)
|
||||
fail with `PartialChain`. To refresh the bundled cert when the root rotates,
|
||||
re-extract from the cluster and overwrite `step-ca-root.crt`:
|
||||
|
||||
```bash
|
||||
kubectl get secret -n cert-manager step-ca-root \
|
||||
-o jsonpath='{.data.ca\.crt}' | base64 -d > step-ca-root.crt
|
||||
```
|
||||
|
||||
```bash
|
||||
cd apps/github-runner
|
||||
podman build -t localhost/fc-github-runner:v20260525-ruby3.3.11-stepca .
|
||||
podman run --rm localhost/fc-github-runner:v20260525-ruby3.3.11-stepca ruby -v
|
||||
podman run --rm localhost/fc-github-runner:v20260525-ruby3.3.11-stepca \
|
||||
test -f /opt/runner-toolcache/Ruby/3.3/x64.complete
|
||||
podman save localhost/fc-github-runner:v20260525-ruby3.3.11-stepca \
|
||||
-o fc-github-runner-v20260525-ruby3.3.11-stepca.tar
|
||||
```
|
||||
|
||||
Import the saved image on every schedulable RKE2 node before ArgoCD rolls the
|
||||
Deployments:
|
||||
|
||||
```bash
|
||||
for node in rke2-server rke2-agent1 rke2-agent2; do
|
||||
scp fc-github-runner-v20260525-ruby3.3.11-stepca.tar "$node:/tmp/"
|
||||
ssh "$node" 'sudo ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images rm localhost/fc-github-runner:v20260525-ruby3.3.11-stepca || true'
|
||||
ssh "$node" 'sudo ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-github-runner-v20260525-ruby3.3.11-stepca.tar'
|
||||
done
|
||||
```
|
||||
|
||||
## Post-Merge Proof
|
||||
|
||||
After the PR is merged and ArgoCD syncs, verify the runner fleet:
|
||||
|
||||
```bash
|
||||
kubectl -n github-runner get deploy,pods,pvc
|
||||
```
|
||||
|
||||
Verify the Ruby toolcache in a fresh pod:
|
||||
|
||||
```bash
|
||||
kubectl -n github-runner exec deploy/github-runner-puppet -c runner -- ruby -v
|
||||
kubectl -n github-runner exec deploy/github-runner-puppet -c runner -- sh -c \
|
||||
'echo "$RUNNER_TOOL_CACHE" && test -f "$RUNNER_TOOL_CACHE/Ruby/3.3/x64.complete"'
|
||||
```
|
||||
|
||||
Verify GitHub registration for the repo-scoped runners:
|
||||
|
||||
```bash
|
||||
for repo in FlowerCore.Common FlowerCore.Shared.Pos FlowerCore.Puppet FlowerCore.Signage \
|
||||
FlowerCore.DMS FlowerCore.Telephony FlowerCore.Print.Web FlowerCore.Chat \
|
||||
FlowerCore.MySQL FlowerCore.Kiosk.Linux FlowerCore.Marquee FlowerCore.TtsReader \
|
||||
FlowerCore.Knowledge FlowerCore.LlmBridge FlowerCore.Media \
|
||||
FlowerCore.Presentations FlowerCore.RemoteDesktop FlowerCore.DNS \
|
||||
FlowerCore.Distribution FlowerCore.Scoreboard FlowerCore.SegmentDisplay \
|
||||
FlowerCore.Signage.Contracts FlowerCore.SignalControl FlowerCore.Intranet.Web \
|
||||
FlowerCore.Provisioning FlowerCore.Redis FlowerCore.MessageBoard \
|
||||
FlowerCore.MenuBoard; do
|
||||
echo "=== $repo ==="
|
||||
gh api "/repos/astoltz/$repo/actions/runners" \
|
||||
--jq '.runners[] | select(.labels[].name == "fc-build-linux") | {name,status,busy,labels:[.labels[].name]}'
|
||||
done
|
||||
```
|
||||
|
||||
Shared.Pos publish proof after the runner pod is online:
|
||||
|
||||
```bash
|
||||
gh run list --repo astoltz/FlowerCore.Shared.Pos \
|
||||
--workflow "Build, Test & Publish" --branch main --limit 5
|
||||
```
|
||||
|
||||
If the latest run is still queued after runner registration, rerun the workflow
|
||||
from GitHub Actions and verify it lands on an `rke2-linux-*` runner.
|
||||
|
||||
## Failure Notes
|
||||
|
||||
- `actions/setup-dotnet` permission error at `/usr/share/dotnet`: check that
|
||||
`DOTNET_INSTALL_DIR=/home/runner/.dotnet` and related cache env vars are
|
||||
present on the runner pod.
|
||||
- `ruby/setup-ruby@v1` says self-hosted runners must install Ruby in
|
||||
`$RUNNER_TOOL_CACHE`: check that the init container copied
|
||||
`/opt/runner-toolcache/Ruby` into `/home/runner/_tool/Ruby` and that
|
||||
`/home/runner/_tool/Ruby/3.3/x64.complete` exists.
|
||||
- `404` during runner registration: the fine-grained PAT is valid but missing
|
||||
repository access for that repo. Add the repo to the PAT access list; the PAT
|
||||
value does not change.
|
||||
- `Multi-Attach` volume error: only the Common runner uses a RWO PVC and it must
|
||||
stay single-replica. New multi-replica runners use `emptyDir`.
|
||||
4592
apps/github-runner/github-runner.yaml
Normal file
4592
apps/github-runner/github-runner.yaml
Normal file
File diff suppressed because it is too large
Load Diff
19
apps/github-runner/install-ruby-toolcache.sh
Normal file
19
apps/github-runner/install-ruby-toolcache.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RUBY_VERSION="${RUBY_VERSION:-3.3.11}"
|
||||
RUBY_MINOR="${RUBY_MINOR:-3.3}"
|
||||
TOOLCACHE_ROOT="${TOOLCACHE_ROOT:-/opt/runner-toolcache}"
|
||||
RUNNER_UID="${RUNNER_UID:-1001}"
|
||||
RUNNER_GID="${RUNNER_GID:-1001}"
|
||||
RUBY_PREFIX="${TOOLCACHE_ROOT}/Ruby/${RUBY_VERSION}/x64"
|
||||
|
||||
mkdir -p "${TOOLCACHE_ROOT}/Ruby"
|
||||
RUBY_CONFIGURE_OPTS="${RUBY_CONFIGURE_OPTS:---disable-install-doc --disable-yjit}" ruby-build "${RUBY_VERSION}" "${RUBY_PREFIX}"
|
||||
|
||||
touch "${TOOLCACHE_ROOT}/Ruby/${RUBY_VERSION}/x64.complete"
|
||||
ln -sfn "${RUBY_VERSION}" "${TOOLCACHE_ROOT}/Ruby/${RUBY_MINOR}"
|
||||
|
||||
"${RUBY_PREFIX}/bin/ruby" -v
|
||||
chown -R "${RUNNER_UID}:${RUNNER_GID}" "${TOOLCACHE_ROOT}"
|
||||
chmod -R a+rX "${TOOLCACHE_ROOT}"
|
||||
12
apps/github-runner/step-ca-root.crt
Normal file
12
apps/github-runner/step-ca-root.crt
Normal file
@@ -0,0 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBxDCCAWqgAwIBAgIRAPY357G6ow6zMAL5+4bS2kkwCgYIKoZIzj0EAwIwQDEa
|
||||
MBgGA1UEChMRSUFtV29ya2luIEFDTUUgQ0ExIjAgBgNVBAMTGUlBbVdvcmtpbiBB
|
||||
Q01FIENBIFJvb3QgQ0EwHhcNMjYwMzA4MTgwNzExWhcNMzYwMzA1MTgwNzExWjBA
|
||||
MRowGAYDVQQKExFJQW1Xb3JraW4gQUNNRSBDQTEiMCAGA1UEAxMZSUFtV29ya2lu
|
||||
IEFDTUUgQ0EgUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ2n04X1
|
||||
JZo5Zdq/i1Idv8+fqwZyAzBh7whbqj0SWsJL8UWRabCMqYCs7+dXO0xRSzqkwFDL
|
||||
x+vooOai8RgRNhajRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/
|
||||
AgEBMB0GA1UdDgQWBBRnuPPQR6iM/H6vOluiU3Sygayz8jAKBggqhkjOPQQDAgNI
|
||||
ADBFAiEArQK9dYPGmAZsdYnjziuFVVE5NKZUcceYvGfGC+tLXUsCIAudF2zJrCRq
|
||||
3mK50ZZET/fwTkJwiEF4824mjP8p1CKM
|
||||
-----END CERTIFICATE-----
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,28 @@ kind: Namespace
|
||||
metadata:
|
||||
name: intranet
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: intranet-vector-store
|
||||
namespace: intranet
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: intranet-config
|
||||
namespace: intranet
|
||||
data:
|
||||
KnowledgeApiKey: ""
|
||||
TrustedHeaderSharedSecret: ""
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -12,6 +34,8 @@ metadata:
|
||||
app: intranet-web
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: intranet-web
|
||||
@@ -22,7 +46,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: intranet-web
|
||||
image: localhost/fc-intranet-web:latest
|
||||
image: localhost/fc-intranet-web:v20260531-ttsreader-bridge
|
||||
imagePullPolicy: Never
|
||||
ports:
|
||||
- containerPort: 5300
|
||||
@@ -32,25 +56,58 @@ spec:
|
||||
value: Production
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:5300"
|
||||
# Bulk corpus indexing on edge1 Pi 5 takes ~6s/chunk × 5665 chunks
|
||||
# ≈ 9 hours. BLUEJAY-WS GPU (R9700, 32GB VRAM) does the same work
|
||||
# in minutes. Memory: feedback_pi5_nomic_embed_slow.
|
||||
- name: IntranetSearch__OllamaBaseUrl
|
||||
value: "http://10.0.56.20:11434"
|
||||
# Sprint E Phase 2α — JSON-file-backed PageReadingOverride persistence
|
||||
# on the writable PVC at /data. Without this env var the
|
||||
# intranet falls back to the in-memory store (loses state on
|
||||
# pod restart). Master's PageReadingOverrideOptions binds
|
||||
# PageReadingOverrides:FilePath.
|
||||
- name: PageReadingOverrides__FilePath
|
||||
value: "/data/page-reading-overrides.json"
|
||||
- name: KnowledgeFleetSearch__BaseUrl
|
||||
value: "https://knowledge.iamworkin.lan"
|
||||
- name: KnowledgeFleetSearch__ApiKey
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: intranet-config
|
||||
key: KnowledgeApiKey
|
||||
optional: true
|
||||
- name: TrustedHeaderAuthentication__SharedSecret
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: intranet-config
|
||||
key: TrustedHeaderSharedSecret
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5300
|
||||
initialDelaySeconds: 10
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5300
|
||||
initialDelaySeconds: 5
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- name: vector-store
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: vector-store
|
||||
persistentVolumeClaim:
|
||||
claimName: intranet-vector-store
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
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.
|
||||
266
apps/knowledge/knowledge.yaml
Normal file
266
apps/knowledge/knowledge.yaml
Normal file
@@ -0,0 +1,266 @@
|
||||
# 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
|
||||
# step-ca ACME caps lifetime at 30d; requesting 90d silently capped
|
||||
# made renewBefore=cert-lifetime → perpetual renewal loop (10888+ CRs
|
||||
# in 18h on 2026-05-07). Match working 720h/240h pattern from other
|
||||
# FC services.
|
||||
duration: 720h # 30d (step-ca cap)
|
||||
renewBefore: 240h # 10d
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
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
|
||||
93
apps/kubevirt-vms/ci1.yaml
Normal file
93
apps/kubevirt-vms/ci1.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
# =============================================================================
|
||||
# ci1 - Windows Server 2025 KubeVirt VM (GitHub Actions Self-Hosted Runner)
|
||||
# =============================================================================
|
||||
# Boots from the sysprepped containerDisk template built by the Windows VM
|
||||
# sysprep pipeline. See docs/infrastructure/windows-vm-sysprep-pipeline.md.
|
||||
# Path A/B/C install history is preserved in git log only.
|
||||
# =============================================================================
|
||||
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: kubevirt-vms
|
||||
labels:
|
||||
app.kubernetes.io/part-of: kubevirt-stack
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
|
||||
---
|
||||
apiVersion: kubevirt.io/v1
|
||||
kind: VirtualMachine
|
||||
metadata:
|
||||
name: ci1
|
||||
namespace: kubevirt-vms
|
||||
labels:
|
||||
app: ci-runner
|
||||
role: github-actions-runner
|
||||
flowercore.io/managed-by: bluejay-infra
|
||||
spec:
|
||||
runStrategy: Halted
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ci-runner
|
||||
role: github-actions-runner
|
||||
kubevirt.io/vm: ci1
|
||||
spec:
|
||||
domain:
|
||||
cpu:
|
||||
cores: 8
|
||||
sockets: 1
|
||||
threads: 1
|
||||
memory:
|
||||
guest: 16Gi
|
||||
resources:
|
||||
requests:
|
||||
memory: 16Gi
|
||||
limits:
|
||||
memory: 16Gi
|
||||
clock:
|
||||
utc: {}
|
||||
timer:
|
||||
hpet:
|
||||
present: false
|
||||
pit:
|
||||
tickPolicy: delay
|
||||
rtc:
|
||||
tickPolicy: catchup
|
||||
hyperv: {}
|
||||
features:
|
||||
acpi: {}
|
||||
apic: {}
|
||||
hyperv:
|
||||
relaxed: {}
|
||||
vapic: {}
|
||||
spinlocks:
|
||||
spinlocks: 8191
|
||||
smm: {}
|
||||
firmware:
|
||||
bootloader:
|
||||
efi:
|
||||
secureBoot: false
|
||||
devices:
|
||||
tpm: {}
|
||||
disks:
|
||||
- name: rootdisk
|
||||
disk:
|
||||
bus: virtio
|
||||
interfaces:
|
||||
# Pod-network fallback for CI runner outbound traffic. Switch to
|
||||
# prod-vlan57 once the bridge/NAD lane is ready for L2 access.
|
||||
- name: default
|
||||
masquerade: {}
|
||||
model: virtio
|
||||
machine:
|
||||
type: q35
|
||||
networks:
|
||||
- name: default
|
||||
pod: {}
|
||||
volumes:
|
||||
- name: rootdisk
|
||||
containerDisk:
|
||||
image: localhost/fc-win-server-2025:v1
|
||||
imagePullPolicy: Never
|
||||
terminationGracePeriodSeconds: 3600
|
||||
3
apps/kubevirt-vms/kustomization.yaml
Normal file
3
apps/kubevirt-vms/kustomization.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
resources:
|
||||
- ci1.yaml
|
||||
- prod-vlan57-nad.yaml
|
||||
69
apps/kubevirt-vms/prod-vlan57-nad.yaml
Normal file
69
apps/kubevirt-vms/prod-vlan57-nad.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
# =============================================================================
|
||||
# NetworkAttachmentDefinition — PROD VLAN 57 bridge
|
||||
# =============================================================================
|
||||
# Purpose: makes KubeVirt VMs reachable on the PROD VLAN (10.0.57.0/24)
|
||||
# alongside the existing pod network. Required for ci1 to bridge onto PROD
|
||||
# (e.g. to provision/scrape edge1, edge2, kiosks, Pis on the same L2 segment).
|
||||
#
|
||||
# **DEPLOY GATE — Phase 1.5 host work required first**:
|
||||
# On every RKE2 node (rke2-server, rke2-agent1, rke2-agent2):
|
||||
# 1. Switch port (UniFi USL16LP) trunks VLAN 57 to the node — usually
|
||||
# already true since BLUEJAY-WS reaches 10.0.57.x services. Verify
|
||||
# with `ip link show enp86s0.57` after configuring sub-interface, OR
|
||||
# `tcpdump -ni enp86s0 vlan 57` and ping a known PROD host.
|
||||
# 2. Linux bridge `br-prod` enslaving `enp86s0.57` (VLAN sub-interface).
|
||||
# NetworkManager profile examples in the runbook below.
|
||||
# 3. Verify Multus DaemonSet `kube-multus-ds` is Ready on all nodes.
|
||||
#
|
||||
# Without those, applying this NAD has no effect except to register the CRD.
|
||||
# A VM that requests this NAD with no bridge present will fail with:
|
||||
# `error adding pod kubevirt-vms_ci1 to CNI network "prod-vlan57": failed to
|
||||
# plumb VLAN: open /sys/class/net/br-prod/master: no such file or directory`
|
||||
#
|
||||
# Configuration notes:
|
||||
# - cniVersion 0.3.1 to match Multus daemon-config.json
|
||||
# - mtu 1500 (matches enp86s0 default; bump if jumbo frames configured)
|
||||
# - bridge name `br-prod` is convention; if Puppet picks a different name
|
||||
# (e.g. `br57`, `br-vlan57`), edit BOTH this NAD and the ci1.yaml
|
||||
# interface block. Keep them in sync.
|
||||
# - vlan: 0 because the host bridge already strips VLAN tag (br-prod sits
|
||||
# on top of `enp86s0.57`). If we instead used a VLAN-aware bridge with
|
||||
# trunk port, set vlan: 57 here. Current convention is VLAN-stripped at
|
||||
# the sub-interface, so the bridge passes untagged frames.
|
||||
#
|
||||
# Apply:
|
||||
# kubectl --kubeconfig $env:USERPROFILE\.kube\rke2.yaml apply -f apps/kubevirt-vms/prod-vlan57-nad.yaml
|
||||
#
|
||||
# Then update ci1.yaml networks: stanza to:
|
||||
# - name: prod-net
|
||||
# multus:
|
||||
# networkName: kubevirt-vms/prod-vlan57
|
||||
# and the interface block from `masquerade` to `bridge`.
|
||||
# =============================================================================
|
||||
|
||||
---
|
||||
# Namespace must exist already (created by ci1.yaml's first document).
|
||||
# This file imports a NAD into that same namespace.
|
||||
apiVersion: k8s.cni.cncf.io/v1
|
||||
kind: NetworkAttachmentDefinition
|
||||
metadata:
|
||||
name: prod-vlan57
|
||||
namespace: kubevirt-vms
|
||||
annotations:
|
||||
bluejay.iamworkin.lan/host-bridge: "br-prod (enslaves enp86s0.57)"
|
||||
bluejay.iamworkin.lan/cidr: "10.0.57.0/24"
|
||||
bluejay.iamworkin.lan/gateway: "10.0.57.1"
|
||||
bluejay.iamworkin.lan/dns: "10.0.56.1 (pfSense Unbound)"
|
||||
spec:
|
||||
config: |
|
||||
{
|
||||
"cniVersion": "0.3.1",
|
||||
"name": "prod-vlan57",
|
||||
"type": "bridge",
|
||||
"bridge": "br-prod",
|
||||
"ipam": {},
|
||||
"mtu": 1500,
|
||||
"vlan": 0,
|
||||
"promiscMode": true,
|
||||
"preserveDefaultVlan": false
|
||||
}
|
||||
99
apps/kubevirt-vms/win2025-iso-nfs-pv.yaml
Normal file
99
apps/kubevirt-vms/win2025-iso-nfs-pv.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
# =============================================================================
|
||||
# Windows Server 2025 ISO — Static NFS PV (Path B for SATA-CDROM timeout)
|
||||
# =============================================================================
|
||||
# Purpose: Mount the ISO from Synology NAS via NFS instead of from a Longhorn-
|
||||
# backed Filesystem PVC.
|
||||
#
|
||||
# Why: SATA-CDROM emulation reading from a Longhorn-backed Filesystem PVC is
|
||||
# too slow for OVMF's boot read window — the DVD-ROM enumeration times out
|
||||
# before the bootloader can be read. Symptom on the serial console:
|
||||
# BdsDxe: failed to start Boot0001 "UEFI QEMU DVD-ROM QM00001 " from ...
|
||||
# BdsDxe: failed to start Boot0001 ... Time out
|
||||
# BdsDxe: No bootable option or device was found
|
||||
# Diagnosis confirmed the ISO content is a perfectly valid bootable ISO9660
|
||||
# image — the bug is in the timing path between OVMF and Longhorn-backed
|
||||
# storage, not in the ISO itself.
|
||||
#
|
||||
# Block-mode PVC was tried (`volumeMode: Block` via DataVolume) and would
|
||||
# likely fix the timing, but CDI v1.65.0's upload-target pod cannot open the
|
||||
# block device due to runAsUser:107 + capabilities.drop:[ALL] and we got:
|
||||
# blockdev: cannot open /dev/cdi-block-volume: Permission denied
|
||||
#
|
||||
# NFS-mounted ISO bypasses both issues: no Longhorn slowness, no CDI upload
|
||||
# pod permission concerns. The ISO is read directly from the NAS over a
|
||||
# native NFSv4.1 mount that QEMU's SATA emulator can read at full LAN speed.
|
||||
#
|
||||
# Layout on Synology:
|
||||
# /volume1/ISOs/ (existing export, RKE2 ACL)
|
||||
# en-us_windows_server_2025_updated_march_2026_x64_dvd_8e06425a.iso
|
||||
# win2025-iso-disk/ (new subdir, 2026-05-08)
|
||||
# disk.img -> hardlink to ../en-us_windows_server_2025_..._8e06425a.iso
|
||||
#
|
||||
# KubeVirt's launcher pod expects a PVC mounted at
|
||||
# /var/run/kubevirt-private/vmi-disks/<diskName>/disk.img — by mounting the
|
||||
# `win2025-iso-disk/` subdir as the NFS PV root, `disk.img` lives at the PV's
|
||||
# root and KubeVirt's CDROM emulator finds it without any path manipulation.
|
||||
#
|
||||
# A symlink would NOT work for sub-path NFS mounts (the relative target
|
||||
# `../...iso` falls outside the sub-mount root). A hardlink works because it
|
||||
# references the same inode regardless of mount point.
|
||||
#
|
||||
# Memory references:
|
||||
# - feedback_synology_nfs_volume1_kubernetes_export_scoped (Synology export
|
||||
# scoping pattern — but /volume1/ISOs export, unlike /volume1/kubernetes,
|
||||
# does support sub-path mounts because Synology NFS is configured with
|
||||
# pseudo-fs in NFSv4.1)
|
||||
# - feedback_kubevirt_iso_first_install_bootorder_and_runstrategy (boot
|
||||
# order / runStrategy gotchas, separate from the storage timing issue)
|
||||
#
|
||||
# Validation (2026-05-08, from rke2-server / rke2-agent1 / rke2-agent2):
|
||||
# mount -t nfs -o nfsvers=4.1,ro 10.0.58.3:/volume1/ISOs/win2025-iso-disk /tmp/m
|
||||
# file /tmp/m/disk.img
|
||||
# -> ISO 9660 CD-ROM filesystem data 'SSS_X64FRE_EN-US_DV9' (bootable)
|
||||
# All 3 RKE2 nodes can mount and read.
|
||||
# =============================================================================
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: windows-server-2025-iso-nfs
|
||||
labels:
|
||||
flowercore.io/iso: windows-server-2025
|
||||
flowercore.io/managed-by: bluejay-infra
|
||||
spec:
|
||||
capacity:
|
||||
storage: 8Gi
|
||||
accessModes:
|
||||
- ReadOnlyMany
|
||||
volumeMode: Filesystem
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
storageClassName: "" # static, no provisioner
|
||||
mountOptions:
|
||||
- nfsvers=4.1
|
||||
- ro
|
||||
- hard
|
||||
- timeo=600
|
||||
- retrans=3
|
||||
nfs:
|
||||
server: 10.0.58.3 # BlueJayNAS Synology DS1621+ on HOME VLAN 58
|
||||
path: /volume1/ISOs/win2025-iso-disk
|
||||
readOnly: true
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: windows-server-2025-iso-nfs
|
||||
namespace: kubevirt-vms
|
||||
labels:
|
||||
app: ci-runner
|
||||
flowercore.io/managed-by: bluejay-infra
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadOnlyMany
|
||||
volumeMode: Filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 8Gi
|
||||
storageClassName: ""
|
||||
volumeName: windows-server-2025-iso-nfs
|
||||
@@ -207,20 +207,13 @@ spec:
|
||||
- port: 993
|
||||
targetPort: 993
|
||||
name: imaps
|
||||
---
|
||||
# TLS Certificate via cert-manager
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: mail-tls
|
||||
namespace: mail
|
||||
spec:
|
||||
secretName: mail-tls
|
||||
issuerRef:
|
||||
name: step-ca-acme
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- mail.iamworkin.lan
|
||||
# --- mail-tls Certificate REMOVED 2026-06-01 ---
|
||||
# mail-tls is now managed OUTSIDE cert-manager: issued from step-ca's JWK 'admin'
|
||||
# provisioner and auto-renewed by a systemd timer on noc1 (step ca renew), which
|
||||
# writes the mail-tls secret directly. step-ca-acme only has an HTTP-01 (Traefik)
|
||||
# solver, but mail.iamworkin.lan must resolve to the dedicated MetalLB IP 10.0.56.202
|
||||
# (SMTP/IMAP), so HTTP-01 cannot validate. Do NOT re-add a cert-manager Certificate
|
||||
# here unless a DNS-01 solver is deployed for step-ca-acme.
|
||||
---
|
||||
# Traefik IngressRoute - Webmail placeholder
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
|
||||
762
apps/monitoring/fc-updatecenter-dashboard.grafana.txt
Normal file
762
apps/monitoring/fc-updatecenter-dashboard.grafana.txt
Normal file
@@ -0,0 +1,762 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [
|
||||
{
|
||||
"icon": "external link",
|
||||
"includeVars": false,
|
||||
"keepTime": false,
|
||||
"targetBlank": true,
|
||||
"title": "Open Service",
|
||||
"type": "link",
|
||||
"url": "https://updatecenter.iamworkin.lan/"
|
||||
}
|
||||
],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": {
|
||||
"color": "#f87171",
|
||||
"index": 1,
|
||||
"text": "DOWN"
|
||||
},
|
||||
"1": {
|
||||
"color": "#4ade80",
|
||||
"index": 0,
|
||||
"text": "UP"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#f87171",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "#4ade80",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "value_and_name"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "probe_success{job=\"probe-traefik-services\",instance=\"updatecenter.iamworkin.lan\"}",
|
||||
"refId": "A",
|
||||
"legendFormat": "Availability"
|
||||
}
|
||||
],
|
||||
"title": "Service Availability",
|
||||
"transparent": true,
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"decimals": 2,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#f87171",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "#fbbf24",
|
||||
"value": 95
|
||||
},
|
||||
{
|
||||
"color": "#FFB300",
|
||||
"value": 99
|
||||
},
|
||||
{
|
||||
"color": "#4ade80",
|
||||
"value": 99.9
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "background_solid",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "value_and_name"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "avg_over_time(probe_success{job=\"probe-traefik-services\",instance=\"updatecenter.iamworkin.lan\"}[24h]) * 100",
|
||||
"refId": "A",
|
||||
"legendFormat": "24h Uptime"
|
||||
}
|
||||
],
|
||||
"title": "24-Hour Uptime",
|
||||
"transparent": true,
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"max": 30,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#f87171",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "#fbbf24",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"color": "#4ade80",
|
||||
"value": 7
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "d"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 0
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"minVizHeight": 75,
|
||||
"minVizWidth": 75,
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "(probe_ssl_earliest_cert_expiry{job=\"probe-traefik-services\",instance=\"updatecenter.iamworkin.lan\"} - time()) / 86400",
|
||||
"refId": "A",
|
||||
"legendFormat": "Days Remaining"
|
||||
}
|
||||
],
|
||||
"title": "Cert Expiry (Days)",
|
||||
"transparent": true,
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "Response Time (seconds)",
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 12,
|
||||
"gradientMode": "scheme",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 4,
|
||||
"showPoints": "never",
|
||||
"spanNulls": true,
|
||||
"thresholdsStyle": {
|
||||
"mode": "dashed"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "#4ade80",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "#fbbf24",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"color": "#f87171",
|
||||
"value": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 14,
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"lastNotNull",
|
||||
"mean",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "right"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "probe_duration_seconds{job=\"probe-traefik-services\",instance=\"updatecenter.iamworkin.lan\"}",
|
||||
"refId": "A",
|
||||
"legendFormat": "Probe Duration"
|
||||
}
|
||||
],
|
||||
"timeFrom": "1h",
|
||||
"title": "Response Time (1h Trend)",
|
||||
"transparent": true,
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 10,
|
||||
"x": 14,
|
||||
"y": 4
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"alertInstanceLabelFilter": "{instance=\"updatecenter.iamworkin.lan\"}",
|
||||
"alertName": "",
|
||||
"dashboardAlerts": false,
|
||||
"groupBy": [],
|
||||
"groupMode": "default",
|
||||
"maxItems": 10,
|
||||
"sortOrder": 1,
|
||||
"stateFilter": {
|
||||
"error": true,
|
||||
"firing": true,
|
||||
"noData": true,
|
||||
"normal": false,
|
||||
"pending": true
|
||||
},
|
||||
"viewMode": "list"
|
||||
},
|
||||
"title": "Active Alerts",
|
||||
"type": "alertlist"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 12
|
||||
},
|
||||
"id": 20,
|
||||
"title": "OTEL Counters — Track 1D",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 10
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 13
|
||||
},
|
||||
"id": 21,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["mean", "lastNotNull"]
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "sum by (status) (rate(updatecenter_manifest_requests_total[5m]))",
|
||||
"refId": "A",
|
||||
"legendFormat": "status={{status}}"
|
||||
}
|
||||
],
|
||||
"title": "Manifest Requests rate by status (5m)",
|
||||
"transparent": true,
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 10
|
||||
},
|
||||
"unit": "Bps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 13
|
||||
},
|
||||
"id": 22,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["mean", "lastNotNull"]
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "sum by (slug) (rate(updatecenter_bundle_download_bytes_total[5m]))",
|
||||
"refId": "A",
|
||||
"legendFormat": "{{slug}}"
|
||||
}
|
||||
],
|
||||
"title": "Bundle Download Throughput by slug (5m)",
|
||||
"transparent": true,
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 10
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 21
|
||||
},
|
||||
"id": 23,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["mean", "lastNotNull"]
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "sum by (status) (rate(updatecenter_checkins_total[5m]))",
|
||||
"refId": "A",
|
||||
"legendFormat": "status={{status}}"
|
||||
}
|
||||
],
|
||||
"title": "Agent Check-in Rate by status (5m)",
|
||||
"transparent": true,
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "#4ade80", "value": null },
|
||||
{ "color": "#f87171", "value": 1 }
|
||||
]
|
||||
},
|
||||
"unit": "none",
|
||||
"decimals": 2
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 21
|
||||
},
|
||||
"id": 24,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["sum"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "value_and_name"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "increase(updatecenter_signature_verify_failures_total[1h])",
|
||||
"refId": "A",
|
||||
"legendFormat": "Sig Verify Failures (1h)"
|
||||
}
|
||||
],
|
||||
"title": "Signature Verify Failures (1h)",
|
||||
"transparent": true,
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 10
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 21
|
||||
},
|
||||
"id": 25,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["mean", "lastNotNull"]
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "sum by (slug, channel) (rate(updatecenter_release_publishes_total[5m]))",
|
||||
"refId": "A",
|
||||
"legendFormat": "{{slug}}/{{channel}}"
|
||||
}
|
||||
],
|
||||
"title": "Release Publishes rate by slug/channel (5m)",
|
||||
"transparent": true,
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 10
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 29
|
||||
},
|
||||
"id": 26,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["mean", "lastNotNull"]
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "sum by (kind, status) (rate(updatecenter_bundle_downloads_total[5m]))",
|
||||
"refId": "A",
|
||||
"legendFormat": "{{kind}} / {{status}}"
|
||||
}
|
||||
],
|
||||
"title": "Bundle Download Requests by kind/status (5m)",
|
||||
"transparent": true,
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 20
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "#4ade80", "value": null },
|
||||
{ "color": "#f87171", "value": 0.01 }
|
||||
]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 29
|
||||
},
|
||||
"id": 27,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["mean", "lastNotNull"]
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "fffjikve8llhce"
|
||||
},
|
||||
"expr": "rate(updatecenter_signature_verify_failures_total[5m])",
|
||||
"refId": "A",
|
||||
"legendFormat": "Sig verify failures/s"
|
||||
}
|
||||
],
|
||||
"title": "Signature Verify Failure Rate (5m) — Critical if >0",
|
||||
"transparent": true,
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"blue-jay",
|
||||
"flowercore",
|
||||
"synthetic",
|
||||
"updatecenter",
|
||||
"otel"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timezone": "browser",
|
||||
"title": "FlowerCore.UpdateCenter Dashboard",
|
||||
"uid": "fc-updatecenter",
|
||||
"version": 2
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user