Compare commits

..

45 Commits

Author SHA1 Message Date
Robot
8ac3557b01 fc-apple-mdm: add NanoHUB GitOps workload 2026-06-17 17:57:17 -05:00
Andrew Stoltz
4b58b0ca5f deploy: align gateway key field 2026-06-16 21:08:03 -05:00
Andrew Stoltz
bd8adb2188 deploy: add MCP gateway for Agent Zero 2026-06-16 21:01:52 -05:00
Andrew Stoltz
d32abd62c8 deploy(chat): chat-web v20260616-circuit-mood-5711f2d
Ships the Blazor circuit-resilience + mood + telemetry fixes to live chat:
- FcAiChat reconnect-resync (stuck _generating after a circuit drop)
- fc-blazor-start.js client serverTimeout 60s (fewer spurious 1006 reconnects)
- ChatToolVisibility (tool plumbing hidden in personality chat)
- mood empathy fix (avatar no longer "excited" on bad news)
- fc-circuit-telemetry.js + /api/clientlog sink (kubectl-readable circuit data)

Image built on noc1 + imported to rke2-server + rke2-agent1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:29:01 -05:00
Andrew Stoltz
204001a89d deploy(dns): pin current DNS image 2026-06-16 11:45:13 -05:00
Andrew Stoltz
6950010ea4 ops(github-runner): scale tts-reader runner to 0 (crash-looping, memory relief)
The github-runner-tts-reader pod was crash-looping (329 restarts) and consuming
memory on the over-pressured old rke2 cluster (rke2-agent1 ~81%), contributing
to Blazor SignalR circuit drops on ttsreader/chat. It provides no working CI in
this state. Set replicas: 0 so ArgoCD stops re-creating it; restore to 1 once
the runner is fixed or CI moves to a working host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:27:40 -05:00
Andrew Stoltz
b28ab73a19 deploy(ttsreader): pin TT-3 endpoint fix image 2026-06-16 05:33:43 -05:00
Andrew Stoltz
09398d451f deploy(ttsreader): pin TT-3 plane health image 2026-06-16 05:15:48 -05:00
Andrew Stoltz
3a7978ab1f deploy(dns): pin DN-3b drift image 2026-06-15 20:56:30 -05:00
Andrew Stoltz
c0bfcb46fa deploy(dns): pin DN-3 PowerDNS image 2026-06-15 20:33:18 -05:00
Andrew Stoltz
ebbf501038 deploy(dns): pin DN-2 MCP bridge image 2026-06-15 20:15:42 -05:00
Andrew Stoltz
d4f24f6f43 deploy(dns): wire MCP transport key 2026-06-15 19:58:52 -05:00
Andrew Stoltz
9f4805f1d6 deploy(dns): pin DN-2 entity CRUD image 2026-06-15 19:52:42 -05:00
Andrew Stoltz
b9a81fb4c0 deploy(dns): pin DN-1 rate-limit image 2026-06-15 19:07:56 -05:00
Andrew Stoltz
a4ccd30429 deploy(chat): require intranet fallback citation image 2026-06-15 18:25:19 -05:00
Andrew Stoltz
09b22e32c2 deploy(chat): pin activation hardened citation image 2026-06-15 18:21:54 -05:00
Andrew Stoltz
5bb136554d deploy(chat): pin citation card fallback image 2026-06-15 18:16:15 -05:00
Andrew Stoltz
485710230b deploy(chat): pin citation card image 2026-06-15 18:06:08 -05:00
Andrew Stoltz
f016375419 deploy(chat): pin citation route fix image 2026-06-15 17:50:21 -05:00
Andrew Stoltz
fc64638029 deploy(chat): pin citation fallback fix-forward 2026-06-15 17:32:39 -05:00
Andrew Stoltz
6b751b0fbe deploy(chat): pin citation fallback image 2026-06-15 17:26:03 -05:00
Andrew Stoltz
a03dbe166d deploy(library): pin RL5 fine action image 2026-06-15 15:56:20 -05:00
Andrew Stoltz
6febe1fdb3 deploy(dns): enable production auth profile 2026-06-15 15:08:03 -05:00
Andrew Stoltz
40fd35ba44 deploy(chat): pin CH-6 presence image 2026-06-14 19:26:31 -05:00
Andrew Stoltz
17654835e7 gx10/platform: step-ca-acme issuer + Traefik HelmChart (migration platform layer)
Bootstrap manifests for the GX10 cluster platform layer (NUC->GX10 migration).
Direct-applied to GX10 + LIVE: step-ca-acme ClusterIssuer Ready (ACME->noc1 step-ca),
Traefik v3.6.10 via RKE2 HelmChart CRD at MetalLB VIP 10.0.57.202 (prod-pool, temp
parallel-run; no clash with live old .200). Under gx10/ NOT apps/* to avoid the old
ApplicationSet auto-deploying GX10 manifests to the OLD cluster.
2026-06-14 18:06:25 -05:00
Andrew Stoltz
63b8d4b667 Deploy Chat regroup CH-3 image 2026-06-14 18:01:43 -05:00
Andrew Stoltz
2c12f35f75 agent-zero: fix fc_dms netpol egress port (8080 = pod targetPort, not svc 80)
NetworkPolicy matches the destination POD port. dms-web svc:80 -> containerPort
8080, so the egress must allow 8080 (the fc-chat rule already allows 80+8080,
which is why chat worked and dms timed out). Add 8080 to the fc-dms egress.
2026-06-14 16:25:25 -05:00
Andrew Stoltz
e33fe81823 agent-zero: connect fc_dms MCP (product-manager fan-out, first server)
AZ only had fc_chat (chat-session) + fc_knowledge (RAG) — so it had no product
capabilities (the 'mysql manager' gap). Wire fc_dms (dynamic message signs, ~13
tools): OnePasswordItem dms-mcp-keys (1P 'FlowerCore DMS MCP Keys' field credential)
-> DMS_MCP_API_KEY -> X-Api-Key; builder adds fc_dms; netpol egress fc-dms:80.
Proven: dms-web/mcp returns 200 with this key. presentations/messageboard/
segmentdisplay/telephony 1P MCP-key items exist for the same pattern; mysql+signage
need 1P items provisioned first (mysql/mcp 401s with no key). Watch context budget.
2026-06-14 16:19:34 -05:00
Andrew Stoltz
ef6afdd577 fc-llm-bridge: repoint Ollama to GX10 NodePort (fix AZ MTU black-hole)
The PROD-VLAN VIP 10.0.57.201 MTU-black-holes Agent Zero's ~150KB requests
(full prompt + 108 MCP tools) -> connection reset mid-stream -> AZ 'same message
again' loop. Switch FlowerCore__Chat__OllamaBaseUrl to the INFRA-VLAN NodePort
10.0.56.14:30976 (same VLAN as the old cluster, carries 150KB fine). Verified:
150KB POST = 200 via NodePort, times out via VIP. NodePort pinned to 30976 on GX10.
2026-06-14 15:12:05 -05:00
Andrew Stoltz
62ca7dacf6 telephony: deploy ARI abort-fix image v20260614-arifix; drop 3600s band-aids
Image -> v20260614-arifix (Telephony 86ff0d0: ReceiveAsync no longer cancelled).
Remove the WebSocketKeepAliveTimeoutSeconds/WebSocketReceiveTimeoutSeconds=3600
band-aids; the code now disables the pong deadline by default and ignores the
receive timeout (liveness = keepalive ping + WebSocketException/Close).
2026-06-14 14:36:11 -05:00
Andrew Stoltz
d03a92407d gx10/tts: persist Piper /tts source + manifest (telephony TTS port baseline)
Dockerfile (linux/arm64, en_US-amy-medium baked), tts_service.py (16kHz/16-bit/mono
WAV, numpy resample 22050->16000), gx10-tts.yaml (CPU NodePort 30850, no GPU request),
README (build/import/cutover/verify on the GX10 cluster).
2026-06-14 14:14:59 -05:00
Andrew Stoltz
e4d1735d35 telephony: make TTS cutover EFFECTIVE via Tts__PiperUrl env (overrides configmap)
Root cause: the live deploy carried env Tts__PiperUrl=edge1 (drifted, not in git)
which shadows appsettings Tts.PiperUrl. Codify Tts__PiperUrl=GX10 + Ari__ env to
match live so git is source-of-truth; the configmap edit alone was inert.
2026-06-14 14:12:02 -05:00
Andrew Stoltz
15edcb7c71 telephony: cut TTS over to GX10 (10.0.56.14:30850, amy-medium); keep edge1 warm
- Tts.PiperUrl edge1 10.0.57.17:8500 -> GX10 NodePort 10.0.56.14:30850
- add netpol egress to GX10 TTS; keep edge1 egress as rollback target
- DefaultEngine piper / SampleRate 8000 unchanged (sln16 16kHz path)
2026-06-14 14:01:50 -05:00
Andrew Stoltz
284ca84166 agent-zero: GX10 system prompt rewrite (tool-calling + RAG rules, strip dead lanes)
Sync the bluejay-profile ConfigMap's embedded system_prompt.md with the
rewritten scripts/agent-zero/agents/bluejay/system_prompt.md: Ollama section
-> GX10 hub (VIP 10.0.57.201, GB10/121GiB); model table with tool-calling
flags (qwen2.5 = tools, gemma3 = 400-on-tools/vision-only, nomic = embed);
new 'Models & Tool-Calling' + 'Knowledge & RAG' rule blocks; stripped dead
WSL/R9700/.132/host.docker.internal/port-30050 lanes; de-pinned test counts;
'Blu' team is persona vocabulary not a fixed team. Personality preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:40:25 -05:00
Andrew Stoltz
7a86c40cf1 fix(telephony): ARI receive timeout 45s->3600s — the real false-abort root cause
Cancelling ClientWebSocket.ReceiveAsync via CancellationToken ABORTS the
socket (a half-read WS frame can't resume). The per-iteration
iterationCts.CancelAfter(WebSocketReceiveTimeoutSeconds) therefore aborted a
healthy idle ARI WebSocket every 45s (state=Aborted), not the keepalive pong
(proven: loop persisted after pong-timeout 15s->3600s). A large receive
timeout lets ReceiveAsync block harmlessly while the PBX is idle; real drops
still surface immediately as WebSocketException -> reconnect. Proper code fix
(stop using CancelAfter on the receive) tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:04:13 -05:00
Andrew Stoltz
de5c9f39fd deploy(devicemgmt): pin regroup web image 2026-06-14 12:52:30 -05:00
Andrew Stoltz
d5311de676 fix(telephony): stop ARI WebSocket false-abort loop (pong-timeout 15s->3600s)
Asterisk res_http_websocket does not reliably answer client PING frames
with PONG, so .NET KeepAliveTimeout (default 15s) aborted a healthy idle
ARI WebSocket every ~45s (ping@30s + pong-wait@15s), dropping StasisStart
events so the *100 IVR intermittently answered with no audio. Generous pong
timeout stops the false aborts; genuine drops still caught by the 45s
receive-timeout state re-check and TCP-level WebSocketException.

Surfaced by FlowerCore.Telephony.SipTests Call_Star100_ReceivesAudibleAudioStream
(0 RTP packets while ExtToExt RTP-hook passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:50:12 -05:00
Andrew Stoltz
7b4f57bb97 deploy(updater): pin regroup web image 2026-06-14 12:45:39 -05:00
Andrew Stoltz
c569c05ad7 deploy(retail-library): roll regroup web images 2026-06-14 12:38:57 -05:00
Andrew Stoltz
fc8297041a deploy(fc-chat): roll effective-prompt debug reveal v20260614-debugreveal-d389e4b
Influence Audit panel now surfaces the per-turn effective prompt
(RagContextSnapshot) as an operator/debug row. FlowerCore.Chat d389e4b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:33:37 -05:00
Andrew Stoltz
e1554757e8 deploy(fc-chat): roll user-bubble prompt-leak fix v20260614-bubblefix-37f57b0
Stored/displayed user message is now the raw prompt; injected scaffolding
(mood contract + guidance + memory) goes to the model via ragContext as a
system message and is captured in RagContextSnapshot for debug.
FlowerCore.Chat 37f57b0 + FlowerCore.Common 4d741b3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:15:26 -05:00
Andrew Stoltz
0c8e6ee8ab agent-zero(models): tool-capable qwen2.5 on GX10 via fc-llm-bridge (Wiring A)
Agent Zero's agentic tool-loop ran on cloud Anthropic Sonnet (the bridge's
Anthropic key is currently 401) + gemma3:4b util (gemma3 returns 400 "does not
support tools" — fatal for the loop). Repoint the bridge ModelRouter tiers:
Balanced -> Ollama qwen2.5:14b (AZ chat) and Cheap -> qwen2.5:7b (AZ util), both
on the GX10 VIP 10.0.57.201 (already the bridge OllamaBaseUrl). Env-only, no
rebuild; Wiring A keeps the budget ledger + cache. Also: AZ chat ctx -> 32768,
browser -> qwen2.5:7b (text/tool-capable, vision off), AGENT_NAME -> "Blue Jay"
(the NUC role is retired). qwen2.5:7b + :14b pulled + warm-pinned on the GX10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:38:17 -05:00
Andrew Stoltz
9d5a1cce97 deploy(fc-chat): roll mood-signal build v20260614-moodsignal-a606892
Workstream A: set_mood structured signal replaces leaky [mood:X] text
(FlowerCore.Chat a606892). Image built + imported to rke2-server and
rke2-agent1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:21:47 -05:00
Andrew Stoltz
e0460bd881 infra(ai): consolidate fleet Ollama consumers onto GX10 VIP 10.0.57.201
Repoints fc-chat, fc-ttsreader, knowledge, fc-llm-bridge (off the slow edge1
Pi5 10.0.57.17) and intranet (off the reimaged BLUEJAY-AI test laptop
10.0.56.132) to the GX10 (DGX Spark / GB10) Ollama over the PROD MetalLB VIP
10.0.57.201. GX10 serves gemma3:12b/gemma3:4b/qwen2.5:1.5b/nomic-embed-text/
llama3.2:1b on local NVMe, warm-pinned (keep_alive=-1).

fc-chat default model qwen2.5-coder:7b -> gemma3:12b (the coder model won't
pull reliably on the GX10; gemma3:12b is the warm fleet default + a better
general-chat model). Other consumers keep their exact models. Inline comments
referencing edge1/BLUEJAY-AI are now historical; the values are the GX10 VIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:54:36 -05:00
Robot
303c450bc9 Cl-5: Admin console infra finding — rides DM.Web (zero new infra)
Audit of apps/fc-devicemgmt/ confirms the admin/helpdesk console needs NO new
infra: the existing host-matched IngressRoute (devices.iamworkin.lan, no path
constraint) + step-ca-acme Certificate already cover admin routes served under
FlowerCore:PathBase (ADR-204 routes-inside-DM.Web). ADMIN-CONSOLE-INFRA.md
records the finding + the open Q-MP question (distinct admin hostname vs PathBase
path) with the exact 3-step add if a separate host is later chosen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:22:16 -05:00
49 changed files with 18061 additions and 18322 deletions

View File

@@ -139,6 +139,31 @@ metadata:
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
---
# FlowerCore DMS Manager MCP key (product-manager fan-out). Synced from the
# 1Password "FlowerCore DMS MCP Keys" item (field `credential`) into Secret
# `dms-mcp-keys`; the deployment reads it as DMS_MCP_API_KEY for the fc_dms
# MCP server. presentations/messageboard/segmentdisplay/telephony 1P MCP-key
# items also exist and follow this same pattern when added.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: dms-mcp-keys
namespace: agent-zero
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore DMS MCP Keys"
---
# FlowerCore MCP Gateway key. Agent Zero advertises only fc_gateway so product
# tool schemas are discovered on demand instead of dumped into the prompt.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: gateway-mcp-keys
namespace: agent-zero
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Gateway MCP Keys"
---
apiVersion: apps/v1
kind: Deployment
@@ -248,35 +273,15 @@ spec:
# use the bridge's Ollama-compatible root via OLLAMA_HOST.
mkdir -p /a0/usr/plugins/_model_config
cat > /a0/usr/plugins/_model_config/config.json << 'MODELCFG'
{"allow_chat_override":true,"chat_model":{"provider":"openai","name":"fc:balanced","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_history":0.7,"vision":false,"kwargs":{"temperature":0,"num_ctx":8192}},"utility_model":{"provider":"openai","name":"fc:cheap","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_input":0.7,"kwargs":{"num_ctx":8192}},"embedding_model":{"provider":"openai","name":"openai/fc:embedding","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","kwargs":{}}}
{"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":32768,"ctx_history":0.7,"vision":false,"kwargs":{"temperature":0,"num_ctx":32768}},"utility_model":{"provider":"openai","name":"fc:cheap","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_input":0.7,"kwargs":{"num_ctx":8192}},"embedding_model":{"provider":"openai","name":"openai/fc:embedding","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","kwargs":{}}}
MODELCFG
# Strip heredoc indentation
sed -i 's/^ //' /a0/usr/plugins/_model_config/config.json
# Phase 0 Chat MCP pilot: Agent Zero does not interpolate env vars
# inside A0_SET_mcp_servers JSON, so build the final JSON here from
# the secret-backed env vars before initialize.sh. Keep the local
# corpus_search.py tool mounted either way so outage fallback
# remains available even when fc_knowledge is not advertised.
export KNOWLEDGE_MCP_ENABLED=false
if [ -n "${KNOWLEDGE_MCP_BEARER_TOKEN:-}" ]; then
if curl -sf --connect-timeout 3 "${KNOWLEDGE_MCP_HEALTH_URL}" > /dev/null && \
curl -sf --connect-timeout 5 \
-H "Authorization: Bearer ${KNOWLEDGE_MCP_BEARER_TOKEN}" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"fc-knowledge-bootstrap","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agent-zero-bootstrap","version":"1.0"}}}' \
"${KNOWLEDGE_MCP_URL}" > /dev/null; then
export KNOWLEDGE_MCP_ENABLED=true
echo "fc_knowledge enabled from ${KNOWLEDGE_MCP_URL}."
else
echo "fc_knowledge unavailable or unauthorized; keeping local corpus_search.py as the fallback path."
fi
else
echo "fc_knowledge token missing; keeping local corpus_search.py as the fallback path."
fi
# Agent Zero does not interpolate env vars inside
# A0_SET_mcp_servers JSON, so build the final JSON here from the
# secret-backed gateway key before initialize.sh.
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=(",", ":")))'
python3 -c 'import json, os; key=os.getenv("GATEWAY_MCP_API_KEY"); url=os.getenv("GATEWAY_MCP_URL", "http://fc-gateway.fc-gateway.svc/mcp"); servers={"fc_gateway":{"type":"streamable-http","url":url,"headers":{"X-Api-Key":key}}} if key else {}; print(json.dumps({"mcpServers": servers}, separators=(",", ":")))'
)"
# Run the original entrypoint
exec /exe/initialize.sh $BRANCH
@@ -285,7 +290,7 @@ spec:
env:
# Agent identity
- name: AGENT_NAME
value: "Blue Jay (NUC)"
value: "Blue Jay"
# 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.
@@ -344,7 +349,7 @@ spec:
- name: A0_SET_browser_model_provider
value: "ollama"
- name: A0_SET_browser_model_name
value: "gemma3:4b"
value: "qwen2.5:7b"
- name: A0_SET_browser_model_api_base
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
- name: A0_SET_browser_model_api_key
@@ -353,11 +358,19 @@ spec:
name: fc-llm-bridge-api-keys
key: agent-zero-k8s
- name: A0_SET_browser_model_vision
value: "true"
value: "false"
- 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"
# FlowerCore.Mcp.Gateway — single MCP fan-in for product tools.
- name: GATEWAY_MCP_URL
value: "http://fc-gateway.fc-gateway.svc/mcp"
- name: GATEWAY_MCP_API_KEY
valueFrom:
secretKeyRef:
name: gateway-mcp-keys
key: credential
# Agent profile — Blue Jay personality, tools, and system prompt
- name: A0_SET_agent_profile
value: "bluejay"
@@ -393,6 +406,20 @@ spec:
secretKeyRef:
name: knowledge-mcp-tokens
key: password
# FlowerCore DMS Manager MCP (dynamic message signs) — first of the
# product-manager MCP fan-out. dms-web /mcp requires X-Api-Key; the key
# is synced from 1Password "FlowerCore DMS MCP Keys" (field credential)
# by the dms-mcp-keys OnePasswordItem CRD above. Same builder+env+netpol
# pattern extends to presentations/messageboard/segmentdisplay/telephony
# (all have 1P MCP-key items). MySQL + Signage still need 1P MCP items
# provisioned before they can join (mysql-web /mcp 401s with no key today).
- name: DMS_MCP_URL
value: "http://dms-web.fc-dms.svc/mcp"
- name: DMS_MCP_API_KEY
valueFrom:
secretKeyRef:
name: dms-mcp-keys
key: credential
# 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).
@@ -637,6 +664,30 @@ spec:
ports:
- port: 5300
protocol: TCP
# FlowerCore DMS Manager MCP (product-manager fan-out) — in-cluster
# dms-web. NetworkPolicy matches the destination POD port: dms-web svc:80
# targets containerPort 8080, so the egress MUST allow 8080 (not the svc
# port 80) — same as the fc-chat rule. Allow both for parity.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-dms
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP
# FlowerCore.Mcp.Gateway — Agent Zero advertises this single MCP server;
# the gateway performs backend search and invokes product MCP tools.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-gateway
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP
# Allow internet (for kubectl image pull, etc)
- to:
- ipBlock:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
# Runtime secret placeholder for the self-hosted Apple MDM substrate.
#
# OnePasswordItem operator syncs this item into a Kubernetes Secret with the
# same name. Expected fields for MDM-N1:
# NANOHUB_API_KEY
#
# Optional fields for later lanes:
# NANOHUB_WEBHOOK_URL
# APNS_MDM_CERT_PEM
# APNS_MDM_KEY_PEM
# APNS_MDM_TOPIC
# SCEP_CA_CERT_PEM
# SCEP_CA_KEY_PEM
# PROFILE_SIGNING_CERT_PEM
# PROFILE_SIGNING_KEY_PEM
#
# Do not commit APNs, SCEP, profile-signing, webhook, or API key material to
# Git. MDM-N1 only consumes NANOHUB_API_KEY and optional NANOHUB_WEBHOOK_URL.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: fc-apple-mdm-runtime
namespace: fc-apple-mdm
labels:
app.kubernetes.io/name: fc-apple-mdm
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 Apple MDM Runtime"

View File

@@ -0,0 +1,65 @@
# FlowerCore Apple MDM Infra
This app hosts the private NanoHUB bootstrap service for FlowerCore iPad
management at `https://mdm.iamworkin.lan`.
## Runtime Shape
- Namespace: `fc-apple-mdm`
- Host: `mdm.iamworkin.lan`
- Image: `localhost/fc-apple-mdm-nanohub:v0.2.0-20260617`
- Upstream baseline: NanoHUB `v0.2.0`, published 2025-12-25
- Persistent data: `fc-apple-mdm-data` mounted at `/var/lib/nanohub`
- NanoHUB file backend root: `/var/lib/nanohub/db`
- Runtime secret: `OnePasswordItem/fc-apple-mdm-runtime`
- Required secret field: `NANOHUB_API_KEY`
- Optional secret field: `NANOHUB_WEBHOOK_URL`
NanoHUB listens on HTTP `:9004` inside the pod; Traefik owns TLS using
`Certificate/fc-apple-mdm-tls`. The public route intentionally exposes only
`/mdm`, `/checkin`, and `/version`. The NanoHUB APIs under `/api/v1/*` stay
cluster-internal for MDM-N1 and are intended for the FlowerCore
DeviceManagement bridge.
## NanoHUB Endpoints
- Device command/report and default check-in endpoint: `/mdm`
- Separate check-in endpoint enabled by `NANOHUB_CHECKIN=true`: `/checkin`
- Health/version endpoint: `/version`
- Internal NanoMDM API: `/api/v1/nanomdm/`
- Internal NanoCMD API: `/api/v1/nanocmd/`
- Internal KMFDDM API: `/api/v1/ddm/`
NanoHUB API authentication is HTTP Basic with username `nanohub` and password
from `NANOHUB_API_KEY`.
## Operator Gates
1. Create `FlowerCore Apple MDM Runtime` in the `IAmWorkin` 1Password vault with
field `NANOHUB_API_KEY`. Add `NANOHUB_WEBHOOK_URL` only after the
DeviceManagement Nano bridge endpoint is live.
2. Add or confirm `mdm.iamworkin.lan -> 10.0.56.200` in FlowerCore.DNS/pfSense
before cert-manager syncs the certificate.
3. Mirror or build the pinned NanoHUB image, then import it on every schedulable
RKE2 node:
```bash
podman pull --arch arm64 ghcr.io/micromdm/nanohub:latest@sha256:e36a50db2dc3d2bf736645e58712f622c04b05b28487390981905ef4d0be5fbd
podman tag ghcr.io/micromdm/nanohub@sha256:e36a50db2dc3d2bf736645e58712f622c04b05b28487390981905ef4d0be5fbd localhost/fc-apple-mdm-nanohub:v0.2.0-20260617
podman save localhost/fc-apple-mdm-nanohub:v0.2.0-20260617 -o fc-apple-mdm-nanohub-v0.2.0-20260617.tar
# copy to each RKE2 node, then:
sudo ctr -n k8s.io images import fc-apple-mdm-nanohub-v0.2.0-20260617.tar
```
If GHCR changes or becomes unavailable, rebuild/import from
`nanohub-linux-arm64-v0.2.0.zip` with SHA-256
`b05968322a9bc34e79169ebee28d16554046f981eaee48a12cf80899f51a9dbd`.
4. Sync the ArgoCD app and prove `https://mdm.iamworkin.lan/version`.
## Support Boundary
This MDM-N1 lane deploys the protocol substrate only. It does not create an APNs
MDM push certificate, enrollment profile, SCEP/device identity service, managed
Wi-Fi payload, managed app install, or supervised iPad enrollment. Those stay in
MDM-N2 through MDM-N8.

View File

@@ -0,0 +1,29 @@
# Certificate for mdm.iamworkin.lan.
#
# Preflight gate: FlowerCore.DNS / pfSense must contain an explicit A record:
# mdm.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.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-apple-mdm-tls
namespace: fc-apple-mdm
labels:
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
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: "mdm.iamworkin.lan must resolve to 10.0.56.200 before ACME sync"
spec:
secretName: fc-apple-mdm-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- mdm.iamworkin.lan
duration: 720h
renewBefore: 240h

View File

@@ -0,0 +1,127 @@
# Self-hosted NanoHUB lane for FlowerCore Apple device management.
#
# Image contract:
# Mirror/import localhost/fc-apple-mdm-nanohub:v0.2.0-20260617 from
# ghcr.io/micromdm/nanohub:latest@sha256:e36a50db2dc3d2bf736645e58712f622c04b05b28487390981905ef4d0be5fbd
# or rebuild from nanohub-linux-arm64-v0.2.0.zip with SHA-256
# b05968322a9bc34e79169ebee28d16554046f981eaee48a12cf80899f51a9dbd.
# Keep imagePullPolicy: Never so the RKE2 nodes do not depend on GHCR at
# runtime.
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-apple-mdm
namespace: fc-apple-mdm
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
app.kubernetes.io/part-of: flowercore
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
strategy:
type: Recreate
selector:
matchLabels:
app: fc-apple-mdm
template:
metadata:
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/version"
prometheus.io/scrape: "false"
flowercore.io/audit-trace-id: "apple-mdm-nanohub-runtime-trace"
spec:
securityContext:
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: nanohub
image: localhost/fc-apple-mdm-nanohub:v0.2.0-20260617
imagePullPolicy: Never
ports:
- name: http
containerPort: 9004
env:
- name: HOME
value: "/var/lib/nanohub"
- name: NANOHUB_LISTEN
value: ":9004"
- name: NANOHUB_STORAGE
value: "file"
- name: NANOHUB_STORAGE_DSN
value: "/var/lib/nanohub/db"
- name: NANOHUB_CHECKIN
value: "true"
- name: NANOHUB_API_KEY
valueFrom:
secretKeyRef:
name: fc-apple-mdm-runtime
key: NANOHUB_API_KEY
- name: NANOHUB_WEBHOOK_URL
valueFrom:
secretKeyRef:
name: fc-apple-mdm-runtime
key: NANOHUB_WEBHOOK_URL
optional: true
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
startupProbe:
httpGet:
path: /version
port: 9004
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /version
port: 9004
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /version
port: 9004
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: data
mountPath: /var/lib/nanohub
- name: tmp
mountPath: /tmp
volumes:
- name: data
persistentVolumeClaim:
claimName: fc-apple-mdm-data
- name: tmp
emptyDir: {}

View File

@@ -0,0 +1,29 @@
# LAN ingress for NanoHUB.
#
# Traefik terminates step-ca TLS; NanoHUB listens on HTTP :9004 and serves the
# Apple MDM protocol endpoints. The NanoHUB API stays cluster-internal for
# MDM-N1; do not route /api/v1 through Traefik until the operator approves an
# API exposure model.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-apple-mdm
namespace: fc-apple-mdm
labels:
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
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(`mdm.iamworkin.lan`) && (PathPrefix(`/mdm`) || PathPrefix(`/checkin`) || PathPrefix(`/version`))
kind: Rule
services:
- name: fc-apple-mdm
port: 80
tls:
secretName: fc-apple-mdm-tls

View File

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

View File

@@ -0,0 +1,13 @@
# FlowerCore Apple MDM namespace.
#
# ArgoCD discovers this directory as Application `infra-fc-apple-mdm`.
apiVersion: v1
kind: Namespace
metadata:
name: fc-apple-mdm
labels:
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra

View File

@@ -0,0 +1,94 @@
# FlowerCore Apple MDM network isolation.
#
# Public/LAN device traffic enters through Traefik. NanoHUB API access is kept
# cluster-internal for MDM-N1 and is reachable by the DeviceManagement bridge.
# Egress 443 is required for Apple APNs/ADE/VPP endpoints once APNs and Apple
# enrollment material are configured in later lanes.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: fc-apple-mdm-isolation
namespace: fc-apple-mdm
labels:
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
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-apple-mdm
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- port: 9004
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-devicemgmt
ports:
- port: 9004
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
# Apple APNs/ADE/VPP endpoints and upstream certificate checks.
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
protocol: TCP
# Traefik VIP / in-cluster Traefik for public URL self-checks. 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
# DeviceManagement bridge webhook/API target.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-devicemgmt
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP

View File

@@ -0,0 +1,25 @@
# Persistent NanoHUB file backend state.
#
# NanoHUB stores NanoMDM, NanoCMD, and KMFDDM data under the file backend root.
# RWO: keep a single replica and use Recreate for disruptive image/runtime
# changes.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fc-apple-mdm-data
namespace: fc-apple-mdm
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/tenant-id: system
flowercore.io/created-by: bluejay-infra
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi

View File

@@ -0,0 +1,22 @@
apiVersion: v1
kind: Service
metadata:
name: fc-apple-mdm
namespace: fc-apple-mdm
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
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-apple-mdm
ports:
- name: http
port: 80
targetPort: 9004
protocol: TCP

View File

@@ -34,10 +34,10 @@ data:
# proved Chat pods time out reaching 10.0.56.20:11434. Keep generation and
# behavior-rule checks on the cluster-routable edge1 endpoint until that route
# is fixed; choose models that edge1 actually hosts.
FlowerCore__AI__OllamaBaseUrl: "http://10.0.57.17:11434"
FlowerCore__AI__DefaultModelName: "qwen2.5-coder:7b"
ChatOptions__BehaviorRuleEngine__OllamaBaseUrl: "http://10.0.57.17:11434"
ChatOptions__BehaviorRuleEngine__FallbackOllamaBaseUrl: "http://10.0.57.17:11434"
FlowerCore__AI__OllamaBaseUrl: "http://10.0.57.201:11434"
FlowerCore__AI__DefaultModelName: "gemma3:12b"
ChatOptions__BehaviorRuleEngine__OllamaBaseUrl: "http://10.0.57.201:11434"
ChatOptions__BehaviorRuleEngine__FallbackOllamaBaseUrl: "http://10.0.57.201:11434"
ChatOptions__BehaviorRuleEngine__ModelName: "gemma3:4b"
FlowerCore__AI__Memory__UseSharedIndexingAdapter: "true"
FlowerCore__AI__Memory__UseOllamaEmbeddings: "true"
@@ -47,6 +47,8 @@ data:
FlowerCore__AI__Skills__Library__LibraryApiUrl: "http://library-web.fc-library.svc.cluster.local"
FlowerCore__AI__Skills__Retail__RetailApiUrl: "http://retail-web.fc-retail.svc.cluster.local"
FlowerCore__AI__Skills__Intranet__IntranetBaseUrl: "http://intranet-web.intranet.svc.cluster.local"
FlowerCore__AI__Skills__Intranet__SearchBaseUrl: "https://intranet.iamworkin.lan"
FlowerCore__AI__Skills__Intranet__PublicBaseUrl: "https://intranet.iamworkin.lan"
FlowerCore__AI__Skills__Print__PrintMcpBaseUrl: "http://10.0.57.16:5200"
FlowerCore__AI__Helpdesk__SentimentEscalation__Enabled: "true"
FlowerCore__AI__IrcBridge__Enabled: "true"
@@ -123,7 +125,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch
containers:
- name: chat-web
image: localhost/fc-chat-web:v20260614-wave5-sentiment-685f62c
image: localhost/fc-chat-web:v20260616-circuit-mood-5711f2d
imagePullPolicy: Never
ports:
- name: http

View File

@@ -0,0 +1,70 @@
# Admin / Helpdesk Console — Infra Finding (Cl-5, ADR-204)
**Outcome: ZERO new cluster infra required.** The Admin/helpdesk console rides the
existing `FlowerCore.DeviceManagement.Web` deploy as routes inside DM.Web (ADR-204).
The ingress already in this directory covers every path the admin console serves.
## What already exists for DM.Web (this directory)
| Manifest | Resource | Notes |
|----------|----------|-------|
| `certificate-web.yaml` | cert-manager `Certificate` `fc-devicemgmt-web-tls` | `issuerRef``step-ca-acme` `ClusterIssuer`; `dnsNames: [devices.iamworkin.lan]`; `secretName: fc-devicemgmt-web-tls`. DNS preflight gate documented (pfSense A record `devices.iamworkin.lan → 10.0.56.200` required before ACME sync). |
| `ingressroute-web.yaml` | Traefik `IngressRoute` `fc-devicemgmt-web` | `entryPoints: [websecure]`, `match: Host(\`devices.iamworkin.lan\`)`, service `fc-devicemgmt-web:80`, `tls.secretName: fc-devicemgmt-web-tls`. |
| `service-web.yaml` | `Service` `fc-devicemgmt-web` (ClusterIP, 80→8080) | Owned by the DM.Web deploy. |
| `deployment-web.yaml` | `Deployment` `fc-devicemgmt-web` | Currently `replicas: 0` (gated on fc-mysql operator + `flowercore_devicemgmt` DB + 1Password runtime item — see header comment). Not a Cl-5 concern. |
| also present | operator RBAC, namespace, network-policy, 1password-item | Full app dir, ArgoCD-managed. |
## Why the admin console needs nothing new
The existing IngressRoute matches **`Host(\`devices.iamworkin.lan\`)` with no `PathPrefix`
constraint**. Traefik therefore forwards *all* paths on that host to the
`fc-devicemgmt-web` service — including any admin/helpdesk routes the DM.Web app exposes
under its `FlowerCore:PathBase` (e.g. `/admin`, `/helpdesk`). The same TLS secret
(`fc-devicemgmt-web-tls`) and the same step-ca ACME `Certificate` already protect them.
This matches the established TLS-only-app pattern (e.g. `apps/fc-library/fc-library.yaml`,
`apps/fc-retail/fc-retail.yaml`): `Certificate` (issuerRef `step-ca-acme` ClusterIssuer) +
host-matched `IngressRoute` sharing the `secretName`. Per ADR-204 the admin console's
Deployment/Service stay with the DM.Web deploy — no separate workload is created.
ArgoCD repo URL convention (for reference, not changed here):
`http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git`
(internal HTTP — step-ca cert isn't trusted by ArgoCD). Apps in `apps/*` are picked up by
the `bluejay-infra` ApplicationSet directory generator; this dir has no `kustomization.yaml`,
consistent with that pattern.
## Recommendation
**Ride DM.Web at a PathBase path → no new Certificate, no new IngressRoute, no new
Deployment/Service.** Close the lane. The admin console reaches users at
`https://devices.iamworkin.lan/<PathBase>` through the manifests already in this directory.
## Open question (operator decision — NOT actioned)
**Q-MP-ADMIN-HOST — Distinct admin hostname vs PathBase path under DM.Web?**
If the operator ever wants the admin/helpdesk console on its *own* hostname
(e.g. `admin.iamworkin.lan`) rather than a path under `devices.iamworkin.lan`, that is a
deliberate routing/auth-surface choice, not a mechanical infra add. It would require:
1. a pfSense / FlowerCore.DNS A record `admin.iamworkin.lan → 10.0.56.200` (ACME preflight
gate — step-ca HTTP-01 can't see the CoreDNS wildcard);
2. a second cert-manager `Certificate` (`step-ca-acme` ClusterIssuer, `dnsNames:
[admin.iamworkin.lan]`, own `secretName`);
3. a second host-matched `IngressRoute` → the same `fc-devicemgmt-web:80` service
(still no new Deployment/Service — same app behind a second host).
**Default taken (do not block): PathBase path under DM.Web = zero new infra.** A separate
admin hostname is left UNBUILT pending an explicit operator answer to Q-MP-ADMIN-HOST,
because it changes the public/auth surface and conflicts with the ADR-204 "routes inside
DM.Web" intent. If the answer is "separate host," author only the `Certificate` +
`IngressRoute` above (no Deployment/Service), mirroring `apps/fc-library/fc-library.yaml`.
## Verification
- `kubectl apply --dry-run=client` (kubectl v1.34.2, no live cluster): `ingressroute-web.yaml`,
`service-web.yaml`, `deployment-web.yaml` validated clean. `certificate-web.yaml` returned
"no matches for kind Certificate in cert-manager.io/v1" — expected with no cluster
connection (CRD discovery unavailable client-side); the YAML shape is identical to the
proven `fc-library` Certificate. Server-side dry-run + live host resolution =
**fix-forward** (cluster may be unreachable from this lane).
- No manifest authored or changed by this lane — finding note only.

View File

@@ -83,7 +83,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
image: localhost/fc-devicemgmt-web:v20260613-g3-6555c0d
image: localhost/fc-devicemgmt-web:v20260614-regroup-c5b8f82
imagePullPolicy: Never
ports:
- name: http

View File

@@ -26,12 +26,9 @@ metadata:
spec:
itemPath: "vaults/IAmWorkin/items/dns-oidc-client"
---
# Service X-Api-Key for the cert-manager ACME webhook -> dns-web call path
# (Phase 0 auth-flip). The 1Password operator resolves this item into a K8s
# Secret of the same name; the `api_key` field becomes Secret key `api_key`.
# dns-web reads it as FlowerCore__Auth__ApiKey (FcApiKey scheme, Operator
# principal); dns-acme-webhook sends it as the X-Api-Key header. Dormant while
# FlowerCore__Auth__Enabled=false (all policies allow-all).
# Service X-Api-Key for the cert-manager ACME webhook -> dns-web call path.
# The 1Password operator resolves this item into Secret/dns-api-keys; field
# `api_key` becomes Secret key `api_key`.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
@@ -62,7 +59,7 @@ data:
{
"FlowerCore": {
"Auth": {
"Enabled": false,
"Enabled": true,
"Oidc": {
"Enabled": true,
"Audience": "dns",
@@ -77,7 +74,7 @@ data:
},
"Tenant": {
"DefaultTenantId": "default",
"JwtClaimsEnabled": false,
"JwtClaimsEnabled": true,
"DefaultTenantHosts": [
"dns.iamworkin.lan"
]
@@ -88,6 +85,13 @@ data:
"Distribution": "Warn"
}
}
},
"Dns": {
"RateLimits": {
"PermitLimit": 60,
"WindowSeconds": 60,
"QueueLimit": 0
}
}
}
}
@@ -125,7 +129,7 @@ spec:
fsGroup: 1654
containers:
- name: dns-web
image: localhost/fc-dns-web:v20260615-phase0-hybrid-f77fb94
image: localhost/fc-dns-web:v20260616-dn-current-3626f04
imagePullPolicy: Never
securityContext:
readOnlyRootFilesystem: true
@@ -162,18 +166,22 @@ spec:
name: dns-oidc-client
key: client_secret
optional: true
# Service X-Api-Key accepted by the FcApiKey scheme. The standard
# key maps to an Operator principal (satisfies OperatorPolicy on the
# ACME present/cleanup endpoints). optional:true keeps the pod
# starting if the 1P operator has not yet produced the secret.
- name: FlowerCore__Auth__ApiKey
valueFrom:
secretKeyRef:
name: dns-api-keys
key: api_key
optional: true
- name: FlowerCore__Mcp__ApiKey__Key
valueFrom:
secretKeyRef:
name: dns-api-keys
key: api_key
optional: true
- name: FlowerCore__Mcp__ServiceName
value: flowercore.dns
- name: FlowerCore__Auth__Enabled
value: "false"
value: "true"
- name: FlowerCore__Auth__Oidc__Enabled
value: "true"
- name: FlowerCore__Auth__Oidc__Audience
@@ -233,18 +241,6 @@ spec:
targetPort: 5320
type: ClusterIP
---
# Defense-in-depth ingress isolation for dns-web (Phase 0). NetworkPolicy is
# L3/L4 and cannot path-scope, so it CANNOT restrict only present/cleanup — the
# real control on those endpoints is the X-Api-Key + OperatorPolicy. This policy
# simply confines who may reach dns-web:5320 to known network zones without
# breaking any live path:
# * Traefik pods -> UI/API on dns.iamworkin.lan
# * same fc-dns namespace -> dns-acme-webhook -> present/cleanup
# * cluster pod CIDR (10.42/16) -> in-cluster Prometheus scrape, etc.
# * node + LAN CIDRs -> kubelet probes, noc1 host-net Prometheus
# Egress is intentionally left unrestricted: dns-web must reach pfSense
# (diag_command.php / HTTPS), the K8s API, Authentik OIDC discovery, step-ca,
# and DNS — over-tight egress would break the provider + auth paths.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
@@ -375,7 +371,7 @@ spec:
fsGroup: 1654
containers:
- name: dns-acme-webhook
image: localhost/fc-dns-acme-webhook:v20260615-phase0-hybrid-f77fb94
image: localhost/fc-dns-acme-webhook:v20260616-dn-current-3626f04
imagePullPolicy: Never
securityContext:
readOnlyRootFilesystem: true
@@ -394,10 +390,6 @@ spec:
value: /tls/tls.key
- name: FlowerCore__Dns__AcmeWebhook__ServiceBaseUrl
value: http://dns-web:5320
# X-Api-Key sent to dns-web on present/cleanup so the webhook
# authenticates as an Operator once dns-web auth is enabled.
# optional:true keeps the webhook starting before the 1P secret
# exists; the header is simply omitted when the value is empty.
- name: FlowerCore__Dns__AcmeWebhook__ApiKey
valueFrom:
secretKeyRef:

View File

@@ -0,0 +1,345 @@
# FlowerCore.Mcp.Gateway — Agent Zero MCP tool-router.
# Exposes one AZ-facing MCP endpoint with three meta-tools:
# list_capabilities, search_tools, invoke_tool.
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-gateway
labels:
app.kubernetes.io/part-of: flowercore
---
# Inbound key Agent Zero sends to the gateway as X-Api-Key.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: gateway-mcp-keys
namespace: fc-gateway
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Gateway MCP Keys"
---
# Embedding API key for fc:embedding via fc-llm-bridge. Optional at runtime:
# the gateway falls back to deterministic keyword ranking if embeddings are down.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: fc-llm-bridge-api-keys
namespace: fc-gateway
spec:
itemPath: "vaults/IAmWorkin/items/FC LLM Bridge API Keys"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: mysql-mcp-keys
namespace: fc-gateway
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore MySQL MCP Keys"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: telephony-mcp-keys
namespace: fc-gateway
spec:
itemPath: "vaults/IAmWorkin/items/Twilio IVR MCP Token (Agent Zero)"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: chat-mcp-keys
namespace: fc-gateway
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Chat MCP Keys"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: dms-mcp-keys
namespace: fc-gateway
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore DMS MCP Keys"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: knowledge-mcp-tokens
namespace: fc-gateway
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-gateway
namespace: fc-gateway
labels:
app.kubernetes.io/name: fc-gateway
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: fc-gateway
template:
metadata:
labels:
app.kubernetes.io/name: fc-gateway
app.kubernetes.io/part-of: flowercore
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/healthz"
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics/prometheus"
spec:
securityContext:
runAsNonRoot: true
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
image: localhost/fc-gateway:v20260617-az-gateway
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
- name: FlowerCore__Mcp__ApiKey__Key
valueFrom:
secretKeyRef:
name: gateway-mcp-keys
key: credential
- name: FlowerCore__Mcp__Gateway__Embedding__BaseUrl
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
- name: FlowerCore__Mcp__Gateway__Embedding__Model
value: "fc:embedding"
- name: FlowerCore__Mcp__Gateway__Embedding__Mode
value: "openai"
- name: FlowerCore__Mcp__Gateway__Embedding__ApiKey
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: agent-zero-k8s
optional: true
- name: GW_BACKEND_fc_mysql_KEY
valueFrom:
secretKeyRef:
name: mysql-mcp-keys
key: credential
optional: true
- name: GW_BACKEND_fc_telephony_KEY
valueFrom:
secretKeyRef:
name: telephony-mcp-keys
key: credential
optional: true
- name: GW_BACKEND_fc_chat_KEY
valueFrom:
secretKeyRef:
name: chat-mcp-keys
key: credential
optional: true
- name: GW_BACKEND_fc_dms_KEY
valueFrom:
secretKeyRef:
name: dms-mcp-keys
key: credential
optional: true
- name: GW_BACKEND_fc_knowledge_KEY
valueFrom:
secretKeyRef:
name: knowledge-mcp-tokens
key: password
optional: true
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 384Mi
volumeMounts:
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /home/app/logs
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
startupProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: fc-gateway
namespace: fc-gateway
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: fc-gateway
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-gateway-tls
namespace: fc-gateway
spec:
secretName: fc-gateway-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- gateway.iamworkin.lan
duration: 720h
renewBefore: 240h
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-gateway
namespace: fc-gateway
spec:
entryPoints:
- websecure
routes:
- match: Host(`gateway.iamworkin.lan`)
kind: Rule
services:
- name: fc-gateway
port: 80
tls:
secretName: fc-gateway-tls
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: fc-gateway-netpol
namespace: fc-gateway
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: fc-gateway
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: agent-zero
ports:
- port: 8080
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
ports:
- port: 8080
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- port: 8080
protocol: TCP
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-llm-bridge
ports:
- port: 8080
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-mysql
ports:
- port: 5300
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: telephony
ports:
- port: 5100
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-chat
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-dms
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: knowledge
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ spec:
- envFrom:
- configMapRef:
name: library-web-config
image: localhost/fc-library-web:v20260602-library-owned-deploy-fix1
image: localhost/fc-library-web:v20260615-rl5-fineaction-80981c3
imagePullPolicy: Never
livenessProbe:
failureThreshold: 3

View File

@@ -164,11 +164,33 @@ spec:
name: fc-llm-bridge-api-keys
key: spare-2
optional: true
# Shared.Chat — Ollama (edge1 Pi 5 + AI HAT+, matches bridge default)
# Shared.Chat — GX10 Ollama via the INFRA-VLAN NodePort (10.0.56.14:30976),
# NOT the PROD-VLAN MetalLB VIP (10.0.57.201:11434). The cross-VLAN path to
# the VIP MTU-black-holes LARGE requests: Agent Zero's full prompt (458-line
# system prompt + 108 MCP tool descriptions ~150KB) times out / resets mid-
# stream there ("Connection reset by peer" in OllamaClient.ChatStreamAsync),
# which made AZ loop on "you have sent the same message again". The NodePort is
# same-VLAN as the old cluster (no inter-VLAN hop) and carries 150KB fine.
# (Small chat/embed requests still work on the VIP; only big agentic prompts broke.)
- name: FlowerCore__Chat__OllamaBaseUrl
value: "http://10.0.57.17:11434"
value: "http://10.0.56.14:30976"
- name: FlowerCore__Chat__HttpTimeout
value: "00:05:00"
# Tier routing override (Wiring A, 2026-06-14): repoint Agent Zero's
# chat (Balanced) + util (Cheap) tiers to the GX10's tool-capable
# local qwen2.5. Balanced was Anthropic Sonnet (cloud/cost, and the
# Anthropic key is currently 401); Cheap was gemma3:4b which CANNOT
# call tools (400 does not support tools) — fatal for an agentic loop.
# qwen2.5 instruct supports the tool-calling loop; GX10 has the memory.
# OllamaBaseUrl above points at the GX10 NodePort (10.0.56.14:30976).
- name: FlowerCore__Chat__ModelRouter__DefaultRoutes__Balanced__Provider
value: "Ollama"
- name: FlowerCore__Chat__ModelRouter__DefaultRoutes__Balanced__Model
value: "qwen2.5:14b"
- name: FlowerCore__Chat__ModelRouter__DefaultRoutes__Cheap__Provider
value: "Ollama"
- name: FlowerCore__Chat__ModelRouter__DefaultRoutes__Cheap__Model
value: "qwen2.5:7b"
# Shared.Chat — Anthropic
- name: FlowerCore__Chat__Anthropic__Enabled
value: "true"

View File

@@ -61,7 +61,7 @@ spec:
- envFrom:
- configMapRef:
name: retail-web-config
image: localhost/fc-retail-web:v20260602-retail-owned-deploy-fix5
image: localhost/fc-retail-web:v20260614-regroup-6d81424
imagePullPolicy: Never
livenessProbe:
failureThreshold: 3

View File

@@ -535,7 +535,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
image: localhost/fc-ttsreader-web:v20260614-wave5-help-2f096e3
image: localhost/fc-ttsreader-web:v20260616-tt3-plane-3fe50f0
imagePullPolicy: Never
ports:
- containerPort: 5217
@@ -605,7 +605,7 @@ spec:
- name: TtsReader__Transcription__TimeoutSeconds
value: "300"
- name: TtsReader__Ollama__BaseUrl
value: "http://10.0.57.17:11434"
value: "http://10.0.57.201:11434"
- name: TtsReader__Ollama__DefaultModel
value: "gemma3:4b"
- name: TtsReader__Ollama__TimeoutSeconds

View File

@@ -61,7 +61,7 @@ spec:
nodeName: rke2-server
containers:
- name: web
image: localhost/fc-updater-web:v202605310029-7974fc4
image: localhost/fc-updater-web:v20260614-regroup-bdf4a4a
imagePullPolicy: Never
ports:
- containerPort: 8080

View File

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

View File

@@ -1698,7 +1698,12 @@ metadata:
flowercore.io/runner-repo: tts-reader
flowercore.io/github-repo: FlowerCore.TtsReader
spec:
replicas: 1
# Scaled to 0 2026-06-16: this runner was crash-looping (329 restarts) and
# consuming memory on the over-pressured old cluster (agent1 ~81%), which was
# contributing to Blazor circuit drops on ttsreader/chat. It provides no
# working CI in this state. Restore to 1 once the runner is fixed (or the repo
# CI moves to the GX10 / a working runner).
replicas: 0
selector:
matchLabels:
app.kubernetes.io/name: github-runner-tts-reader

View File

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

View File

@@ -92,7 +92,7 @@ spec:
# down. Bulk embed runs in the background; /health does not depend on it.
# Memory: feedback_pi5_nomic_embed_slow.
- name: IntranetSearch__OllamaBaseUrl
value: "http://10.0.56.132:11434"
value: "http://10.0.57.201:11434"
# Notes docs corpus IS now mounted at /srv/flowercore-notes (see the
# notes-corpus-clone initContainer + notes-corpus-sync sidecar), so the
# IntranetSearch indexer is ENABLED. First-boot bulk embed of the corpus

View File

@@ -168,7 +168,7 @@ spec:
# 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"
value: "http://10.0.57.201:11434"
- name: FlowerCore__Mcp__ApiKey__Key
valueFrom:
secretKeyRef:

View File

@@ -1,7 +1,8 @@
# FlowerCore.Telephony - Blazor Server + REST API + Twilio IVR
# ArgoCD managed - BlueJay Lab
# Credentials: 1Password → OnePasswordItem CRD → K8s Secret (twilio-credentials)
# TTS: Piper on edge1 (10.0.57.17:8500) — endpoint /tts with {"text":"..."}
# TTS: Piper on GX10 (10.0.56.14:30850, en_US-amy-medium) — endpoint /tts with {"text":"..."}
# edge1 (10.0.57.17:8500, amy-low) kept as warm fallback (revert PiperUrl to roll back)
# Public: telephony.flowercore.io via Cloudflare origin cert
---
apiVersion: v1
@@ -24,17 +25,25 @@ data:
---
# 1Password → K8s Secret sync for Twilio credentials
# Creates secret "twilio-credentials" with fields: AccountSid, AuthToken, DefaultFromNumber
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: twilio-credentials
namespace: telephony
spec:
itemPath: "vaults/IAmWorkin/items/Twilio Account"
---
# Application configuration overlay
apiVersion: v1
kind: ConfigMap
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: twilio-credentials
namespace: telephony
spec:
itemPath: "vaults/IAmWorkin/items/Twilio Account"
---
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: telephony-mcp-keys
namespace: telephony
spec:
itemPath: "vaults/IAmWorkin/items/Twilio IVR MCP Token (Agent Zero)"
---
# Application configuration overlay
apiVersion: v1
kind: ConfigMap
metadata:
name: telephony-config
namespace: telephony
@@ -62,7 +71,8 @@ data:
"Password": "bluejay-asterisk-ari",
"Application": "flowercore-pbx",
"ReconnectDelaySeconds": 5,
"MaxReconnectDelaySeconds": 60
"MaxReconnectDelaySeconds": 60,
"WebSocketKeepAliveIntervalSeconds": 30
},
"Sip": {
"Domain": "10.0.56.207",
@@ -70,7 +80,7 @@ data:
"Transport": "udp"
},
"Tts": {
"PiperUrl": "http://10.0.57.17:8500",
"PiperUrl": "http://10.0.56.14:30850",
"DefaultEngine": "piper",
"SampleRate": 8000
},
@@ -114,9 +124,9 @@ spec:
app: telephony-web
template:
metadata:
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/health"
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/health"
labels:
app: telephony-web
spec:
@@ -154,7 +164,7 @@ spec:
topologyKey: kubernetes.io/hostname
containers:
- name: telephony-web
image: localhost/fc-telephony-web:v202604252156
image: localhost/fc-telephony-web:v20260614-arifix
imagePullPolicy: Never
securityContext:
readOnlyRootFilesystem: true
@@ -164,7 +174,7 @@ spec:
ports:
- containerPort: 5100
name: http
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
# fc-safe-to-expose: X-Forwarded-Proto handled by AddFlowerCoreWebAuth (ADR-178) before any future public/OIDC flip.
env:
- name: Telephony__Twilio__AccountSid
valueFrom:
@@ -178,12 +188,28 @@ spec:
name: twilio-credentials
key: AuthToken
optional: true
- name: Telephony__Twilio__DefaultFromNumber
valueFrom:
secretKeyRef:
name: twilio-credentials
key: DefaultFromNumber
optional: true
- name: Telephony__Twilio__DefaultFromNumber
valueFrom:
secretKeyRef:
name: twilio-credentials
key: DefaultFromNumber
optional: true
- name: FlowerCore__Mcp__ApiKey__Key
valueFrom:
secretKeyRef:
name: telephony-mcp-keys
key: credential
optional: true
# Env vars OVERRIDE appsettings.Production.json in ASP.NET Core config.
# These were previously applied live-only (kubectl) and drifted from git;
# codified here so git is the source of truth. Tts__PiperUrl is the real
# TTS cutover lever (the configmap "Tts" block is shadowed by this env).
- name: Tts__PiperUrl
value: "http://10.0.56.14:30850" # GX10 amy-medium; edge1 10.0.57.17:8500 = rollback
- name: Ari__Username
value: "flowercore"
- name: Ari__Password
value: "bluejay-asterisk-ari"
volumeMounts:
- name: telephony-config
mountPath: /app/appsettings.Production.json
@@ -289,17 +315,25 @@ spec:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
# Allow Selenium Grid for automated UI testing
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: selenium
ports:
- port: 5100
protocol: TCP
# Allow SIP/RTP from external sources (Yealink phones, Twilio SIP trunk)
- from:
- ipBlock:
# Allow Selenium Grid for automated UI testing
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: selenium
ports:
- port: 5100
protocol: TCP
# Allow FlowerCore.Mcp.Gateway to reach Telephony /mcp on the destination pod port.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-gateway
ports:
- port: 5100
protocol: TCP
# Allow SIP/RTP from external sources (Yealink phones, Twilio SIP trunk)
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 5060
@@ -320,7 +354,14 @@ spec:
protocol: UDP
- port: 53
protocol: TCP
# Allow Piper TTS on edge1 (10.0.57.17:8500)
# Allow Piper TTS on GX10 (10.0.56.14:30850) — primary
- to:
- ipBlock:
cidr: 10.0.56.14/32
ports:
- port: 30850
protocol: TCP
# Allow Piper TTS on edge1 (10.0.57.17:8500) — warm fallback / rollback target
- to:
- ipBlock:
cidr: 10.0.57.17/32

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

15
gx10/platform/README.md Normal file
View File

@@ -0,0 +1,15 @@
# GX10 cluster platform layer (NOT old-cluster ArgoCD)
These manifests bootstrap the GX10 RKE2 cluster's platform layer for the NUC→GX10
migration. They are **direct-applied** to the GX10 (its own kubectl) during
bootstrap, and live under `gx10/` (NOT `apps/`) so the OLD cluster's bluejay-infra
ApplicationSet (whose `apps/*` generator targets the OLD cluster) does NOT
auto-deploy them there. Once ArgoCD is stood up on the GX10, a GX10-only
ApplicationSet (`apps-gx10/*`) will own these.
- `step-ca-acme.yaml` — cert-manager ClusterIssuer (ACME → noc1 step-ca, in-spec caBundle). APPLIED + Ready.
- `traefik-helmchart.yaml` — Traefik v3.6.10 (chart 39.0.5) via the RKE2 HelmChart CRD, LoadBalancer VIP 10.0.57.202 (prod-pool; temp parallel-run VIP — canonical .200 reclaimed at cutover). APPLIED.
cert-manager v1.17.2 was installed separately (upstream static manifest). See
`docs/ai-agents/gx10-migration-continuation-2026-06-14.md` + memory
`project_gx10_ai_node_2026_06_13`.

View File

@@ -0,0 +1,14 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: step-ca-acme
spec:
acme:
server: https://10.0.56.10:9443/acme/acme/directory
caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJ4RENDQVdxZ0F3SUJBZ0lSQVBZMzU3RzZvdzZ6TUFMNSs0YlMya2t3Q2dZSUtvWkl6ajBFQXdJd1FERWEKTUJnR0ExVUVDaE1SU1VGdFYyOXlhMmx1SUVGRFRVVWdRMEV4SWpBZ0JnTlZCQU1UR1VsQmJWZHZjbXRwYmlCQgpRMDFGSUVOQklGSnZiM1FnUTBFd0hoY05Nall3TXpBNE1UZ3dOekV4V2hjTk16WXdNekExTVRnd056RXhXakJBCk1Sb3dHQVlEVlFRS0V4RkpRVzFYYjNKcmFXNGdRVU5OUlNCRFFURWlNQ0FHQTFVRUF4TVpTVUZ0VjI5eWEybHUKSUVGRFRVVWdRMEVnVW05dmRDQkRRVEJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCSjJuMDRYMQpKWm81WmRxL2kxSWR2OCtmcXdaeUF6Qmg3d2hicWowU1dzSkw4VVdSYWJDTXFZQ3M3K2RYTzB4UlN6cWt3RkRMCngrdm9vT2FpOFJnUk5oYWpSVEJETUE0R0ExVWREd0VCL3dRRUF3SUJCakFTQmdOVkhSTUJBZjhFQ0RBR0FRSC8KQWdFQk1CMEdBMVVkRGdRV0JCUm51UFBRUjZpTS9INnZPbHVpVTNTeWdheXo4akFLQmdncWhrak9QUVFEQWdOSQpBREJGQWlFQXJRSzlkWVBHbUFac2RZbmp6aXVGVlZFNU5LWlVjY2VZdkdmR0MrdExYVXNDSUF1ZEYyekpyQ1JxCjNtSzUwWlpFVC9md1RrSndpRUY0ODI0bWpQOHAxQ0tNCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
privateKeySecretRef:
name: step-ca-acme-account-key
solvers:
- http01:
ingress:
ingressClassName: traefik

View File

@@ -0,0 +1,81 @@
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: traefik
namespace: kube-system
spec:
chart: traefik
repo: https://traefik.github.io/charts
version: "39.0.5"
targetNamespace: traefik-system
createNamespace: true
valuesContent: |
deployment:
replicas: 1
additionalArguments:
- "--api.dashboard=true"
- "--log.level=INFO"
- "--providers.kubernetescrd"
- "--providers.kubernetesingress"
- "--providers.kubernetescrd.allowEmptyServices=true"
- "--providers.kubernetesingress.allowEmptyServices=true"
- "--providers.kubernetesingress.ingressendpoint.publishedservice=traefik-system/traefik"
ingressRoute:
dashboard:
enabled: false
rbac:
enabled: true
service:
type: LoadBalancer
annotations:
metallb.io/loadBalancerIPs: "10.0.57.202"
metallb.io/address-pool: "prod-pool"
ports:
web:
port: 8000
exposedPort: 80
protocol: TCP
websecure:
port: 8443
exposedPort: 443
protocol: TCP
tls:
enabled: true
irc:
port: 6667
exposedPort: 6667
protocol: TCP
expose:
default: true
irctls:
port: 6697
exposedPort: 6697
protocol: TCP
expose:
default: true
traefik:
port: 8080
exposedPort: 8080
protocol: TCP
expose:
default: false
metrics:
port: 9100
exposedPort: 9100
protocol: TCP
expose:
default: false
metrics:
prometheus:
entryPoint: metrics
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
tolerations:
- key: "node-role.kubernetes.io/control-plane"
operator: "Exists"
effect: "NoSchedule"

31
gx10/tts/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# GX10 Piper TTS — linux/arm64 (built natively on the GX10 / DGX Spark, aarch64).
# Serves the telephony /tts contract: POST {"text"} -> 16 kHz/16-bit/mono WAV.
# Voice baked into the image so there is no runtime HuggingFace dependency.
FROM python:3.12-slim
# espeak-ng is the phonemizer backend piper-tts uses at synthesis time.
RUN apt-get update \
&& apt-get install -y --no-install-recommends espeak-ng ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir piper-tts flask numpy
# Bake the voice model (en_US-amy-medium, 22.05 kHz native) into the image.
ARG PIPER_VOICE=en_US-amy-medium
ARG VOICE_BASE=https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/amy/medium
RUN mkdir -p /voices \
&& curl -sSL -o "/voices/${PIPER_VOICE}.onnx" "${VOICE_BASE}/${PIPER_VOICE}.onnx" \
&& curl -sSL -o "/voices/${PIPER_VOICE}.onnx.json" "${VOICE_BASE}/${PIPER_VOICE}.onnx.json" \
&& test -s "/voices/${PIPER_VOICE}.onnx" \
&& test -s "/voices/${PIPER_VOICE}.onnx.json"
COPY tts_service.py /app/tts_service.py
WORKDIR /app
ENV TTS_PORT=8500 \
PIPER_VOICE=en_US-amy-medium \
VOICES_DIR=/voices \
TARGET_RATE=16000
EXPOSE 8500
CMD ["python", "tts_service.py"]

59
gx10/tts/README.md Normal file
View File

@@ -0,0 +1,59 @@
# GX10 Piper TTS — telephony `/tts` endpoint
CPU Piper TTS serving the telephony `/tts` contract on the **GX10 RKE2 cluster**
(ASUS Ascent GX10 / NVIDIA DGX Spark, ARM64, `10.0.56.14`). This is the
telephony-TTS-port-to-GX10 (P1) baseline: edge1 parity at higher quality, zero
GPU/aarch64 risk, frees telephony off the slow edge1 Pi 5.
## What it is
- `tts_service.py` — Flask app: `POST /tts {"text"}`**16 kHz / 16-bit / mono WAV**
(canonical 44-byte header) + `GET /health`. Voice `en_US-amy-medium` (22.05 kHz
native) is numpy-resampled to 16 kHz so it drops straight onto Asterisk's
`.sln16` path (telephony strips the 44-byte header). Same wire contract as the
edge1 `speech-pipeline` `/tts`, just the TTS half (no STT/Wyoming).
- `Dockerfile``linux/arm64`, voice baked in (no runtime HuggingFace dep).
- `gx10-tts.yaml` — Namespace `tts` + Deployment (CPU-only, **no GPU request** so it
co-resides with the GPU-holding Ollama pod) + NodePort Service.
## This cluster is NOT under the old-cluster ArgoCD (yet)
Apply manually with the GX10's own kubectl:
```bash
ssh -J noc1 -i ~/.ssh/fcadmin_ed25519 bluejay@10.0.56.14
export KUBECONFIG=/etc/rancher/rke2/rke2.yaml
K=/var/lib/rancher/rke2/bin/kubectl
$K apply -f gx10-tts.yaml
```
## Build + import (native arm64 on the GX10)
```bash
docker build -t localhost/fc-gx10-tts:v20260614 .
docker save localhost/fc-gx10-tts:v20260614 -o /tmp/t.tar
sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/t.tar
# manifest uses imagePullPolicy: Never (image lives in containerd, no registry)
```
## Telephony cutover (reversible)
Endpoint telephony hits: **`http://10.0.56.14:30850`** (NodePort, MGMT VLAN 56).
In `apps/telephony/telephony.yaml`:
1. Deployment env `Tts__PiperUrl=http://10.0.56.14:30850`**this is the real lever**;
env vars override `appsettings.Production.json`, so the configmap `Tts` block alone
is inert (it was shadowed by a drifted live env `Tts__PiperUrl=edge1`).
2. NetworkPolicy egress to `10.0.56.14/32:30850` (telephony-web is `hostNetwork`, so this
only matters for non-hostNetwork pods; harmless either way).
3. edge1 (`10.0.57.17:8500`) stays warm — **rollback = set `Tts__PiperUrl` back to it**.
The TTS circuit breaker + `MapTextToSound` canned-prompt fallback mean a bad endpoint
degrades gracefully, never to silence.
## Verify (not a manual call)
```bash
FLOWERCORE_SIP_TEST_MODE=required dotnet.exe test \
FlowerCore.Telephony/tests/FlowerCore.Telephony.SipTests/FlowerCore.Telephony.SipTests.csproj \
--filter FullyQualifiedName~Call_Star100_ReceivesAudibleAudioStream
```
A passing audible test alone is NOT sufficient (edge1 also produces audible audio) —
confirm the **GX10 TTS pod's own access log** (`kubectl -n tts logs deploy/gx10-tts`)
shows `POST /tts 200` during the call, and telephony-web logs target `10.0.56.14:30850`.
## Voice upgrade (follow-on)
Operator's pick is **Kokoro**; needs GPU time-slicing (Ollama holds the GB10 GPU; MPS is
refuted on GB10) OR Kokoro-CPU behind a `/tts` shim. This Piper baseline stays as the floor.

81
gx10/tts/gx10-tts.yaml Normal file
View File

@@ -0,0 +1,81 @@
# GX10 Piper TTS — telephony /tts endpoint on the GX10 RKE2 cluster.
# Applied DIRECTLY via the GX10's own kubectl (KUBECONFIG=/etc/rancher/rke2/rke2.yaml);
# the GX10 cluster is NOT yet under the old-cluster ArgoCD. CPU-only (no GPU request)
# so it co-resides with the GPU-holding Ollama pod without contending for the GB10.
# Image is imported into RKE2 containerd (imagePullPolicy: Never).
# Telephony reaches it at http://10.0.56.14:30850 (NodePort, MGMT VLAN 56).
apiVersion: v1
kind: Namespace
metadata:
name: tts
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gx10-tts
namespace: tts
labels:
app: gx10-tts
spec:
replicas: 1
selector:
matchLabels:
app: gx10-tts
template:
metadata:
labels:
app: gx10-tts
spec:
containers:
- name: tts
image: localhost/fc-gx10-tts:v20260614
imagePullPolicy: Never
ports:
- containerPort: 8500
name: http
env:
- name: TTS_PORT
value: "8500"
- name: PIPER_VOICE
value: "en_US-amy-medium"
- name: TARGET_RATE
value: "16000"
readinessProbe:
httpGet:
path: /health
port: 8500
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /health
port: 8500
initialDelaySeconds: 10
periodSeconds: 20
timeoutSeconds: 5
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "4"
memory: "2Gi"
---
apiVersion: v1
kind: Service
metadata:
name: gx10-tts
namespace: tts
labels:
app: gx10-tts
spec:
type: NodePort
selector:
app: gx10-tts
ports:
- name: http
port: 8500
targetPort: 8500
nodePort: 30850
protocol: TCP

153
gx10/tts/tts_service.py Normal file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""GX10 Piper TTS microservice — telephony /tts contract.
POST /tts {"text": "..."} -> 16 kHz / 16-bit / mono WAV (canonical 44-byte header)
GET /health -> JSON status
The telephony AsteriskProvider strips the 44-byte WAV header and writes the
remainder as a `.sln16` (signed-linear 16 kHz) file that Asterisk transcodes to
any codec. So the response MUST be 16 kHz / 16-bit / mono. The en_US-amy-medium
voice is 22.05 kHz native, so we resample to 16 kHz (a 22.05 kHz stream treated
as 16 kHz plays ~1.38x too fast). This is a drop-in upgrade over edge1's
en_US-amy-low (16 kHz native, lower quality), keeping the exact wire contract.
"""
import io
import logging
import os
import sys
import threading
import wave
import numpy as np
from flask import Flask, Response, jsonify, request
API_PORT = int(os.environ.get("TTS_PORT", "8500"))
PIPER_VOICE = os.environ.get("PIPER_VOICE", "en_US-amy-medium")
VOICES_DIR = os.environ.get("VOICES_DIR", "/voices")
TARGET_RATE = int(os.environ.get("TARGET_RATE", "16000"))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
stream=sys.stdout,
)
log = logging.getLogger("gx10-tts")
piper_voice_obj = None
piper_loaded = False
piper_lock = threading.Lock()
native_rate = None
app = Flask(__name__)
def load_piper():
"""Load the Piper voice model once at startup (shared, lock-guarded)."""
global piper_voice_obj, piper_loaded
try:
from piper import PiperVoice
model_path = os.path.join(VOICES_DIR, f"{PIPER_VOICE}.onnx")
if not os.path.isfile(model_path):
log.error("Piper voice model not found at %s — TTS disabled", model_path)
piper_loaded = False
return
log.info("Loading Piper voice %s from %s", PIPER_VOICE, model_path)
piper_voice_obj = PiperVoice.load(model_path)
piper_loaded = True
log.info("Piper voice loaded")
except Exception as exc: # noqa: BLE001 — fail-soft, /health reports it
log.error("Failed to load Piper: %s", exc)
piper_loaded = False
def synthesize_chunks(text):
"""Run Piper synthesis under a lock because the loaded voice is shared."""
with piper_lock:
return list(piper_voice_obj.synthesize(text))
def resample_i16(pcm_i16, src_rate, dst_rate):
"""Linear-interpolation resample of int16 PCM (matches edge1's STT resample)."""
if src_rate == dst_rate or len(pcm_i16) == 0:
return pcm_i16
audio = pcm_i16.astype(np.float32)
target_len = int(round(len(audio) * dst_rate / src_rate))
if target_len <= 0:
return np.zeros(0, dtype=np.int16)
idx = np.linspace(0, len(audio) - 1, target_len)
res = np.interp(idx, np.arange(len(audio)), audio)
return np.clip(np.round(res), -32768, 32767).astype(np.int16)
@app.route("/health", methods=["GET"])
def health():
return jsonify({
"status": "ok",
"voice": PIPER_VOICE,
"loaded": piper_loaded,
"target_rate": TARGET_RATE,
"native_rate": native_rate,
})
@app.route("/tts", methods=["POST"])
def tts():
"""Text -> 16 kHz/16-bit/mono WAV. Mirrors the edge1 speech-pipeline contract."""
if not piper_loaded:
return jsonify({"error": "Piper TTS model not loaded"}), 503
data = request.get_json(silent=True)
if not data or "text" not in data:
return jsonify({"error": "Missing required field: text"}), 400
text = data["text"].strip()
if not text:
return jsonify({"error": "Text field is empty"}), 400
if len(text) > 10000:
return jsonify({"error": "Text too long (max 10000 characters)"}), 400
try:
chunks = synthesize_chunks(text)
if not chunks:
return jsonify({"error": "No audio produced"}), 500
global native_rate
first = chunks[0]
native_rate = first.sample_rate
if first.sample_width != 2 or first.sample_channels != 1:
return jsonify({
"error": f"Unexpected PCM format: width={first.sample_width} "
f"channels={first.sample_channels} (need 16-bit mono)"
}), 500
pcm = np.frombuffer(
b"".join(c.audio_int16_bytes for c in chunks), dtype=np.int16
)
out = resample_i16(pcm, native_rate, TARGET_RATE)
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(TARGET_RATE)
wav_file.writeframes(out.tobytes())
wav_buffer.seek(0)
return Response(
wav_buffer.read(),
mimetype="audio/wav",
headers={"Content-Disposition": 'inline; filename="speech.wav"'},
)
except Exception as exc: # noqa: BLE001
log.error("TTS synthesis failed: %s", exc)
return jsonify({"error": f"Synthesis failed: {exc}"}), 500
if __name__ == "__main__":
log.info(
"GX10 TTS starting on port %d (voice=%s -> %d Hz)",
API_PORT, PIPER_VOICE, TARGET_RATE,
)
load_piper()
app.run(host="0.0.0.0", port=API_PORT, threaded=True)

View File

@@ -814,6 +814,78 @@ public sealed class FleetManifestLintTests
ns.FileText.Should().Contain("ArgoCD discovers this directory as Application `infra-fc-devicemgmt`.");
}
[Fact]
public void FcAppleMdm_NanoHubWorkloadMustStayPinnedAndInternalApiOnly()
{
var documents = AppDocuments("fc-apple-mdm");
documents.Should().Contain(document => document.Kind == "Namespace" && document.Name == "fc-apple-mdm");
documents.Should().Contain(document => document.Kind == "OnePasswordItem" && document.Name == "fc-apple-mdm-runtime");
documents.Should().NotContain(document => document.Kind == "Secret");
var item = documents.Single(document => document.Kind == "OnePasswordItem" && document.Name == "fc-apple-mdm-runtime");
item.Scalar("spec", "itemPath").Should().Be("vaults/IAmWorkin/items/FlowerCore Apple MDM Runtime");
var deployment = documents.Single(document => document.Kind == "Deployment" && document.Name == "fc-apple-mdm");
deployment.Scalar("spec", "strategy", "type").Should().Be("Recreate");
PodAnnotation(deployment, "fc.flowercore.io/healthz-anon").Should().Be("true");
PodAnnotation(deployment, "fc.flowercore.io/probe-path").Should().Be("/version");
PodAnnotation(deployment, "flowercore.io/audit-trace-id").Should().Be("apple-mdm-nanohub-runtime-trace");
var container = deployment.MainContainerMappings().Should().ContainSingle().Subject;
ManifestNodeExtensions.Scalar(container, "name").Should().Be("nanohub");
ManifestNodeExtensions.Scalar(container, "image").Should().Be("localhost/fc-apple-mdm-nanohub:v0.2.0-20260617");
ManifestNodeExtensions.Scalar(container, "imagePullPolicy").Should().Be("Never");
EnvValue(container, "NANOHUB_LISTEN").Should().Be(":9004");
EnvValue(container, "NANOHUB_STORAGE").Should().Be("file");
EnvValue(container, "NANOHUB_STORAGE_DSN").Should().Be("/var/lib/nanohub/db");
EnvValue(container, "NANOHUB_CHECKIN").Should().Be("true");
EnvSecretName(container, "NANOHUB_API_KEY").Should().Be("fc-apple-mdm-runtime");
EnvSecretKey(container, "NANOHUB_API_KEY").Should().Be("NANOHUB_API_KEY");
EnvSecretName(container, "NANOHUB_WEBHOOK_URL").Should().Be("fc-apple-mdm-runtime");
EnvSecretKey(container, "NANOHUB_WEBHOOK_URL").Should().Be("NANOHUB_WEBHOOK_URL");
EnvSecretOptional(container, "NANOHUB_WEBHOOK_URL").Should().Be("true");
ProbePath(container, "readinessProbe").Should().Be("/version");
ProbePath(container, "startupProbe").Should().Be("/version");
ProbePath(container, "livenessProbe").Should().Be("/version");
var certificate = documents.Single(document => document.Kind == "Certificate" && document.Name == "fc-apple-mdm-tls");
certificate.Scalar("spec", "issuerRef", "name").Should().Be("step-ca-acme");
certificate.Scalar("spec", "issuerRef", "kind").Should().Be("ClusterIssuer");
ManifestNodeExtensions.ScalarSequence(certificate.Root, "spec", "dnsNames")
.Should()
.ContainSingle("mdm.iamworkin.lan");
var ingress = documents.Single(document => document.Kind == "IngressRoute" && document.Name == "fc-apple-mdm");
var match = ingress.MappingSequence("spec", "routes")
.Select(route => ManifestNodeExtensions.Scalar(route, "match") ?? string.Empty)
.Should()
.ContainSingle()
.Subject;
match.Should().Contain("Host(`mdm.iamworkin.lan`)");
match.Should().Contain("PathPrefix(`/mdm`)");
match.Should().Contain("PathPrefix(`/checkin`)");
match.Should().Contain("PathPrefix(`/version`)");
match.Should().NotContain("/api/v1", "NanoHUB API access is cluster-internal for MDM-N1");
var service = documents.Single(document => document.Kind == "Service" && document.Name == "fc-apple-mdm");
service.AllScalars().Should().Contain("9004");
var policy = documents.Single(document => document.Kind == "NetworkPolicy" && document.Name == "fc-apple-mdm-isolation");
policy.AllScalars().Should().Contain(new[]
{
"traefik-system",
"fc-devicemgmt",
"10.0.56.200/32",
});
policy.EgressPorts().Should().Contain(new[] { "53", "80", "443", "8080", "8443" });
documents.Should().NotContain(document => document.AllScalars().Any(value =>
value.Contains("micromdm", StringComparison.OrdinalIgnoreCase)
|| value.Contains("MICROMDM", StringComparison.Ordinal)));
}
[Fact]
public void BroaderHardeningDeployments_MustAnnotateAnonymousHealthProbeIntent()
{
@@ -867,7 +939,7 @@ public sealed class FleetManifestLintTests
{
var deployments = new[]
{
(App: "fc-dns", Name: "dns-web", Slug: "dns", Secret: "dns-oidc-client", AuthEnabled: "false"),
(App: "fc-dns", Name: "dns-web", Slug: "dns", Secret: "dns-oidc-client", AuthEnabled: "true"),
(App: "fc-media", Name: "fc-media-web", Slug: "media", Secret: "media-oidc-client", AuthEnabled: "true"),
(App: "fc-distribution", Name: "fc-distribution", Slug: "distribution", Secret: "distribution-oidc-client", AuthEnabled: "true"),
};
@@ -918,6 +990,37 @@ public sealed class FleetManifestLintTests
}
}
[Fact]
public void DnsPhase0_UsesOnePasswordBackedAcmeApiKey()
{
var item = AppDocuments("fc-dns")
.Single(document => document.Kind == "OnePasswordItem" && document.Name == "dns-api-keys");
item.Scalar("spec", "itemPath").Should().Be("vaults/IAmWorkin/items/FlowerCore DNS API Keys");
var dnsWeb = AppDocuments("fc-dns")
.Single(document => document.Kind == "Deployment" && document.Name == "dns-web")
.MainContainerMappings()
.Should()
.ContainSingle()
.Subject;
EnvSecretName(dnsWeb, "FlowerCore__Auth__ApiKey").Should().Be("dns-api-keys");
EnvSecretKey(dnsWeb, "FlowerCore__Auth__ApiKey").Should().Be("api_key");
EnvSecretOptional(dnsWeb, "FlowerCore__Auth__ApiKey").Should().Be("true");
EnvSecretName(dnsWeb, "FlowerCore__Mcp__ApiKey__Key").Should().Be("dns-api-keys");
EnvSecretKey(dnsWeb, "FlowerCore__Mcp__ApiKey__Key").Should().Be("api_key");
EnvSecretOptional(dnsWeb, "FlowerCore__Mcp__ApiKey__Key").Should().Be("true");
var webhook = AppDocuments("fc-dns")
.Single(document => document.Kind == "Deployment" && document.Name == "dns-acme-webhook")
.MainContainerMappings()
.Should()
.ContainSingle()
.Subject;
EnvSecretName(webhook, "FlowerCore__Dns__AcmeWebhook__ApiKey").Should().Be("dns-api-keys");
EnvSecretKey(webhook, "FlowerCore__Dns__AcmeWebhook__ApiKey").Should().Be("api_key");
EnvSecretOptional(webhook, "FlowerCore__Dns__AcmeWebhook__ApiKey").Should().Be("true");
}
[Fact]
public void DnsAndMediaGitOpsAdoption_PreservesLiveStorageAndImageShape()
{
@@ -927,7 +1030,7 @@ public sealed class FleetManifestLintTests
var dnsPvc = AppDocuments("fc-dns")
.Single(document => document.Kind == "PersistentVolumeClaim" && document.Name == "dns-web-data");
ManifestNodeExtensions.Scalar(dnsContainer, "image").Should().Be("localhost/fc-dns-web:v20260613-g5-quota-aa99bd1");
ManifestNodeExtensions.Scalar(dnsContainer, "image").Should().Be("localhost/fc-dns-web:v20260616-dn-current-3626f04");
dnsPvc.Scalar("spec", "storageClassName").Should().Be("longhorn");
dnsPvc.Scalar("spec", "resources", "requests", "storage").Should().Be("1Gi");