Compare commits

..

239 Commits

Author SHA1 Message Date
Andrew Stoltz
fbbc07023b deploy(fc-llm-bridge): roll fc:vision image v202604300022
Source: FlowerCore.LlmBridge@8dd181c (feat: fc:vision route + image
content forwarding). Adds:

- fc:vision tier alias parsing (TryParseTier handles fc:vision,
  FC:VISION, openai/fc:vision, vision)
- Image content forwarding: OpenAi image_url shape (https URL +
  data:[mediaType];base64,... URI) and Anthropic image/source
  passthrough are now promoted to LlmContentBlocks. Text-only
  content-parts arrays still flatten to the legacy joined string.
- DefaultRoutes seeder + appsettings.json gain Vision -> Anthropic +
  claude-sonnet-4-6.

Image built on BLUEJAY-WS, podman save + ctr import to all 3 RKE2
nodes (rke2-server, rke2-agent1, rke2-agent2). Bridge tests: 62/62
green (was 51/51, +11). Backwards-compatible with current chat /
util / embed callers; existing routes unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:26:45 -05:00
Andrew Stoltz
4b0eef0fb0 deploy(fc-llm-bridge): roll alias-fix image v20260430001132 2026-04-30 00:13:48 -05:00
Andrew Stoltz
bb09a3786f fix(knowledge): pin live manifest to bundled edition path 2026-04-29 23:37:02 -05:00
Andrew Stoltz
006dbcf671 fix(agent-zero): export knowledge mcp gate to python builder 2026-04-29 23:32:55 -05:00
Andrew Stoltz
1be71d6ba7 fix(agent-zero): export mcp servers without python indent errors 2026-04-29 23:19:48 -05:00
Andrew Stoltz
0c8026c912 fix(agent-zero): avoid heredoc break in mcp bootstrap 2026-04-29 23:16:54 -05:00
Andrew Stoltz
621ae47e00 fix(agent-zero): repair fc knowledge mcp manifest 2026-04-29 23:11:57 -05:00
Andrew Stoltz
ae6b8c0142 fix(knowledge): keep mcp key env on new token secret 2026-04-29 23:06:07 -05:00
Andrew Stoltz
da55220218 feat(agent-zero): wire fc_knowledge phase1 rollout 2026-04-29 22:59:19 -05:00
Andrew Stoltz
b1ad253dd6 fix(agent-zero): prefix bridge embedding alias for litellm 2026-04-29 21:14:12 -05:00
Andrew Stoltz
ee935f6e07 fix(agent-zero): keep internal util/embed on bridge v1 2026-04-29 21:09:04 -05:00
Andrew Stoltz
2853ee2024 chore(bridge): bump fc-llm-bridge image tag v202604292028 2026-04-29 20:50:55 -05:00
Andrew Stoltz
b4a34e16ca refactor(agent-zero): drop ollama-proxy sidecar (Phase 3) 2026-04-29 20:50:55 -05:00
Andrew Stoltz
0d5a1fd530 fix(agent-zero): route util and embed through llm bridge 2026-04-29 19:14:01 -05:00
Andrew Stoltz
1b633f57b2 chore(infra): wire knowledge MCP api key secret 2026-04-29 18:04:43 -05:00
Andrew Stoltz
ee8afd0a08 deploy(intranet): promote auth-gated intranet image 2026-04-29 17:11:17 -05:00
Andrew Stoltz
cf35884eae deploy(intranet): harden knowledge search rollout 2026-04-29 16:43:09 -05:00
Andrew Stoltz
9881767b11 deploy(intranet): bump intranet web for knowledge search lane 2026-04-29 16:21:27 -05:00
Andrew Stoltz
c9bf23834b chore(ttsreader): bump image to v202604291817
Per-profile MoodAnnotationModelOverride picker — Profiles page now shows
a model dropdown from IModelRegistry instead of a free-text field; model
override null-falls-back to global TtsReader:Ollama:DefaultModel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:21:40 -05:00
Andrew Stoltz
174002023d fix(agent-zero): move corpus_search + intranet_search into bluejay-tools-c
The prior commit b71f9e4 created a stray YAML document between the
bluejay-tools-c and bluejay-profile sections. kubectl applied the stray
block's data to bluejay-profile (wrong ConfigMap, wrong mount target).

The setup-bluejay initContainer copies bluejay-tools-{a,b,c} to the tools
directory; bluejay-profile is copied to the agent profile directory. Tools
must live in one of the three tools ConfigMaps.

Fix: insert corpus_search.py and intranet_search.py directly into the
bluejay-tools-c YAML document (before kind/metadata, matching the
data-first layout the rest of the file uses). Also fix two mojibake
characters (→ and ·) that were corrupted in the prior commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:49:23 -05:00
Andrew Stoltz
b71f9e4ec9 feat(agent-zero): add corpus_search + intranet_search to cluster configmaps
- Add corpus_search.py to bluejay-tools-c: semantic vector search over
  fleet SQLite-vec DBs (fleet-workstation-full, fleet-pi-edge, fleet-bmo-bot).
  Returns offline-friendly results for Bible/Greek/Hebrew/Strongs corpora.
  Cluster pod degrades gracefully (no DB mounted yet — BLUEJAY-WS only for now).

- Add intranet_search.py to bluejay-tools-c: live RAG search over the
  intranet vector store via GET /api/search?q=...&topK=N. Uses in-cluster
  service URL (http://intranet-web.intranet.svc:5300) to bypass Traefik TLS
  and the private-range egress denylist.

- Fix intranet_search.py param name: was 'limit', now 'topK' matching the
  SearchController's [FromQuery] parameter name.

- NetworkPolicy: add egress rule for intranet namespace port 5300 (without
  this the pod's TCP connection to the search endpoint was dropped).

- agent-zero.yaml: set FLOWERCORE_INTRANET_URL env var to in-cluster service
  URL so intranet_search uses internal routing, not the public Traefik VIP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:34:31 -05:00
Andrew Stoltz
f1431f7324 feat(agent-zero): wire Print.Web API key to pod via 1Password OnePasswordItem
Add `print-web-api-keys` OnePasswordItem CRD that syncs from 1Password
"Print.Web API Keys" vault item (password field). Mount as PRINT_WEB_API_KEY
env var in the agent-zero container.

The print_web.py Python tool (already in bluejay-tools ConfigMaps) reads
PRINT_WEB_URL and PRINT_WEB_API_KEY env vars for all HTTP calls to the
thermal print service on edge2. Previously the key was unset so every API
call was rejected with 401.

Note: Print.Web uses the legacy REST MCP shape (/api/mcp/tools/*) not the
streamable-http protocol. The Python tool bridges this gap — no /mcp endpoint
exists on Print.Web today. Network policy already allows 10.0.57.16:5200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:36:36 -05:00
Andrew Stoltz
35bd055cb4 feat(guacamole): add macmini-vnc-creds OnePasswordItem + fix Mac mini connection IPs
Phase 1 of Mac mini onboarding (2026-04-28):
- Add OnePasswordItem CRD 'macmini-vnc-creds' in guacamole namespace bound to
  vault item 'Mac Mini' — operator mints Secret with username/password/VNC Password fields
- Mac mini discovered at 10.0.56.115 (INFRA VLAN) — not 10.0.57.50 stored in 1P IP field
- Guacamole connections updated via API (not stored here): VNC conn #10, SSH conns #9/#33
  corrected from old IP 10.0.57.50 → 10.0.56.115
- macOS: 26.4.1 (Sequoia), Apple M1, 16 GB, user: bluejay (admin group)
- VNC port 5900 confirmed open; SSH works via noc1 jumpbox with password auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:09:45 -05:00
Andrew Stoltz
f604ab419e feat(ttsreader): bump image to v202604281923 (SignalR ProgressHub)
Adds ProgressHub endpoint at /hubs/progress with project-scoped
group broadcasting for JobStarted, CueProgress, JobCompleted, and
JobFailed events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:30:41 -05:00
Andrew Stoltz
b2786252b0 chore(ttsreader): bump web image to v202604281831 (ops failed-manifest cleanup)
Deploys fix for stale Failed manifest accumulation in TTS Reader Ops view
and atomic-write guard against empty/corrupt job manifests.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 18:31:53 -05:00
Andrew Stoltz
45ee40920d fix(ttsreader): bump image to v202604281638 (Range support + Ollama timeout 240s) 2026-04-28 16:44:57 -05:00
Andrew Stoltz
8ad7eb714b fix(ttsreader): bump image to v202604281542 (annotation few-shot prompt + UI hint) 2026-04-28 15:46:28 -05:00
Andrew Stoltz
3cb44c3104 feat(noc-services): wire puppetdb.iamworkin.lan through Traefik step-ca cert 2026-04-28 15:13:20 -05:00
Andrew Stoltz
2400329acd fix(intranet): bump image to v20260428-1500 (Monitoring crash patch + Lane 11 anatomy refresh) 2026-04-28 14:59:27 -05:00
Andrew Stoltz
c17af882cc fix(ttsreader): bump image to v202604281444 for UX polish (cross-chapter Bible passage, /profiles dedup, /ops table) 2026-04-28 14:48:13 -05:00
Andrew Stoltz
76b1938afa fix(ttsreader): bump image to v202604281434 for live playback regression patch (study-player + speech override synth) 2026-04-28 14:43:06 -05:00
Andrew Stoltz
ced04a6148 intranet: bump web image to v20260428-0953
Sprint E XXL Intranet docs depth + read-aloud-root sweep deploy.

Image tag v20260427-2353 → v20260428-0953:
- Track A (Intranet.Web@c4f3d78): 7 service pages deepened toward
  PrintService.razor's 8-tab depth standard. Workflows / Verified
  Surfaces / Recent Verified Changes added.
- Read-aloud-root sweep (Intranet.Web@787982c): data-read-aloud-root
  wrappers added to 6 older /services/* pages so the read-aloud
  overlay scopes content extraction precisely instead of falling back
  to <main> with layout chrome included.
2026-04-28 09:54:27 -05:00
Andrew Stoltz
f2258b92a2 fc-ttsreader: bump web image to v202604280946 + add Render__CdnDirectory env
Sprint E XXL Phase 4γ MVP deploy — POST /api/v1/render endpoint.

Two changes:
1. Image tag v202604272339 → v202604280946 (TtsReader@d9e0a58 master tip
   includes the new RenderController + RenderService + 9 tests).
2. New TtsReader__Render__CdnDirectory=/data/cdn env var. Default
   wwwroot/cdn resolves under the read-only app filesystem when
   runAsNonRoot=true; pin to the existing writable PVC mount alongside
   other TtsReader runtime data. Manifests + cue audio land at
   /data/cdn/sha256/<hash>/manifest.json + cues/.

Pre-existing PVC mount at /data/ already covers this — no PVC change
needed, just the env var override.

Pairs with TtsReader@d9e0a58 master tip (ready for image build + import).
2026-04-28 09:47:46 -05:00
Andrew Stoltz
979a7c7b25 feat(intranet): bump fc-intranet-web to v20260427-2353 + persist PageReadingOverrides
Bump intranet image to v20260427-2353 (master @ 38b0148):
- Sprint E search lane: /search Blazor page + IntranetSearchService
  + DocsCorpusIndexer + Shared.Indexing wiring
- 7 new service pages: LocalAiAgents, AiTopology, Distribution, Dns,
  Knowledge, LlmBridge, Provisioning
- PiManager drift docs

New env var: PageReadingOverrides__FilePath=/data/page-reading-overrides.json
so the persisted Lane 2α store lives on the writable PVC instead of
the default in-memory fallback (which loses state on pod restart).
Operator-edited overrides via the existing /api/v1/pages/{encoded}/overrides
controller will now survive across restarts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:54:17 -05:00
Andrew Stoltz
0df8f7b936 chore(ttsreader): bump fc-ttsreader-web to v202604272339 (Sprint E Phase C — partial-render UX)
TtsReader@9333480: distinguishes partial-render (yellow Warning, audio
plays, 'Re-render N failed sentences' button) from full-fail (red
Danger, 'Try render again'). New TtsFallbackChainFailedException carries
both voices when Kokoro + Piper both fail; chapter breadcrumb names
the entire chain instead of just the requested voice. +8 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:40:19 -05:00
Andrew Stoltz
38558641c1 fix(ttsreader-kokoro): bump liveness probe timeouts (Sprint E Phase 1a)
Kokoro pod has 4 restarts in 2d6h with exit 143 (SIGTERM from kubelet).
kubectl describe events all show:

  Liveness probe failed: Get "http://10.42.229.109:8880/v1/audio/voices":
    context deadline exceeded

The probe path /v1/audio/voices shares the FastAPI worker pool with
/v1/audio/speech. A long synth (Bible chapter, 30+ sentences) holds the
pool past the prior 5s × 3 = 15s probe window, kubelet kills the pod,
in-flight renders fail. Operator hits "fallback chain failed" toasts +
partial-render breadcrumbs during these windows.

Bump probe timeoutSeconds 5 → 15 and failureThreshold 3 → 5 → 75 s of
grace before kubelet gives up. Combined with the kokoro-side circuit
breaker landing in TtsReader (Sprint E Phase 1b), the FC backend will
also stop slamming kokoro during recovery so it can serve the probe
even faster.

The companion Prometheus alerts (KokoroPodFlapping, PiperPodFlapping)
land in FlowerCore.Notes/scripts/monitoring/alerts.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:28:07 -05:00
Andrew Stoltz
63d905b4df chore(ttsreader): bump fc-ttsreader-web to v202604272236 (Thinking + Feedback ALTERs) 2026-04-27 22:37:08 -05:00
Andrew Stoltz
d95f4e0caf chore(ttsreader): bump fc-ttsreader-web to v202604272228 (ChatSessions IsFavorite ALTER hotfix) 2026-04-27 22:28:56 -05:00
Andrew Stoltz
7bc565d17e fix(ttsreader): pin VoicePreview CacheDirectory to /data PVC
Day 8 disk-cache warmer crashes on production with
'Read-only file system : /home/app/data' because the relative default
'data/voice-previews' resolves under runAsNonRoot HOME (read-only with
readOnlyRootFilesystem=true). Pin to /data/voice-previews so the cache
lands on the writable PVC mount alongside ttsreader.db, audio output,
and jobs root.

Image v202604272216 (already on nodes) is unaffected by this — only
the env routing changes. ArgoCD reconciles + rollout restart picks up
the new env without rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:24:04 -05:00
Andrew Stoltz
dfe9c3b67e chore(ttsreader): bump fc-ttsreader-web to v202604272216 (brace-escape fix) 2026-04-27 22:16:19 -05:00
Andrew Stoltz
37f8db89e4 chore(ttsreader): bump fc-ttsreader-web to v202604272208 (Day 10 + VoiceProfiles hotfix)
v202604272157 crash-looped on the production PVC because Database.EnsureCreated()
is a no-op on existing DBs and the VoiceProfiles table was missing. TtsReader@a9f0b73
adds an idempotent CREATE TABLE IF NOT EXISTS to the infra reconciler before
TtsReaderDataSeeder runs. Bumping the manifest to pick up that fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:09:08 -05:00
Andrew Stoltz
00c7d8df24 chore(ttsreader): bump fc-ttsreader-web to v202604272157 (Sprint E Day 10 UX polish)
Compact project page (Setup chip strip + chapter inspect-toggle drawer)
+ render feedback (rolling ETA strip + active-chapter pulse) + Bible
Dashboard navigates to /projects/{id} on queue. Source TtsReader@79de78b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:58:12 -05:00
Andrew Stoltz
c6811eadd8 intranet: bump image to v20260427-newpages-and-topology
Adds 7 new pages (5 service pages, AI topology, opencode operator guide)
to https://intranet.iamworkin.lan:
  /services/dns
  /services/distribution
  /services/llm-bridge
  /services/knowledge
  /services/provisioning
  /services/ai-topology
  /development/local-ai-agents

Plus topology corrections in /services/ai (AiStack.razor) and 6 new nav entries.

Source commit: FlowerCore.Intranet.Web@1598542 on
codex-wip-pre-readaloud-collision-2026-04-24.

Image built from artifacts/publish via Dockerfile.deploy on BLUEJAY-WS,
imported to all 3 RKE2 nodes (rke2-server + rke2-agent1 + rke2-agent2).

Build: 0 warnings, 0 errors, 197/197 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:52:34 -05:00
Andrew Stoltz
4d9d537d83 fix(knowledge): repoint Ollama at edge1 + flip README to LIVE (Sprint E B7)
Two changes after the Phase 2.4 deploy went live at
https://knowledge.iamworkin.lan:

1. **Ollama URL flip**: from BLUEJAY-WS (10.0.56.20:11434) to edge1 Pi 5
   (10.0.57.17:11434). Honors the cluster-clean architecture from
   bluejay-infra@0f9d56e ("Workstation is private dev hardware and should
   not be in the cluster path"). Query-time embeddings (~ms per query)
   are fast enough on edge1; bulk index rebuilds (Phase 2.5+) will need a
   separate ingestion lane that can opt into the workstation GPU when
   present. ArgoCD picks up the env-var change and rolls the pod
   automatically — no image rebuild needed.

2. **README LIVE status**: flip the staged-not-yet-applied banner to
   LIVE 2026-04-27. Pod running, certificate issued, PVC bound,
   /healthz 200, /api/v1/editions [] (initial-deploy state). Phase 2.5+
   admin UI handles bulk population.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:56:35 -05:00
Andrew Stoltz
0f9d56ee16 agent-zero: drop BLUEJAY-WS upstream, edge1 Pi is sole Ollama backend
Workstation (BLUEJAY-WS) is private dev hardware and should not be in the
cluster path. Repointing the nginx ollama-proxy sidecar so cluster Agent Zero
talks ONLY to edge1 Pi 5 + AI HAT+ (10.0.57.17:11434):

- nginx upstream: edge1 sole server, no workstation entry
- wait-for-ollama init container: only checks edge1
- NetworkPolicy egress: drop 10.0.56.20/32, keep 10.0.57.17/32
- Comments updated throughout to flag workstation as off-limits to cluster
- Annotation rewritten to document the architectural intent

Pulled qwen2.5:1.5b on edge1 first so Agent Zero's utility_model survives
the cutover (existing models on edge1: qwen3:4b, gemma3:4b, qwen2.5-coder:7b,
nomic-embed-text). Model count on edge1: 4 → 5.

Lets BLUEJAY-WS lock down its Ollama port to localhost without breaking
the cluster Agent Zero.
2026-04-27 16:30:44 -05:00
Andrew Stoltz
3bf6511d5d feat(knowledge): stage Phase 2.4 K8s deployment manifests (Sprint E B2)
NOT YET APPLIED — push to origin/main is gated on the DNS A record
knowledge.iamworkin.lan -> 10.0.56.200 being live. Per memory
feedback_pfsense_dns_required_for_acme, applying the Certificate
without DNS in place puts cert-manager into ~2h HTTP-01 backoff and
needs `kubectl -n knowledge delete order <name>` recovery.

Manifests authored:
- apps/knowledge/knowledge.yaml — Namespace, PVC (knowledge-vector-store
  Longhorn 20Gi RWO), Deployment (single replica, Recreate, image
  localhost/fc-knowledge-web:v202604272200 placeholder, runAsNonRoot
  1654, readOnlyRootFilesystem, drop ALL caps, /healthz startupProbe +
  readinessProbe, tcpSocket livenessProbe), Service (ClusterIP port
  80 -> 8080), Certificate (step-ca-acme ClusterIssuer, 90d duration),
  IngressRoute (knowledge.iamworkin.lan, websecure entrypoint).
- apps/knowledge/kustomization.yaml — `kubectl kustomize` preview file
  (matches fc-distribution shape; ApplicationSet uses dir generator).
- apps/knowledge/README.md — deployment order checklist with the DNS
  preflight, image build/import loop for all 3 RKE2 nodes, push
  procedure, smoke verification, initial-deploy-state notes
  (zero editions until *.db files are pushed to the PVC), resource
  sizing, probe + middleware notes.

Companion artifacts (separate repos, separate commits):
- FlowerCore.Knowledge@eb91eb4 — Dockerfile.deploy at repo root
- FlowerCore.Notes@96cd443 — scripts/deploy-knowledge.sh

Apply order (from apps/knowledge/README.md):
1. Add DNS A record knowledge.iamworkin.lan -> 10.0.56.200 via
   FlowerCore.DNS or pfSense web UI.
2. Run `bash scripts/deploy-knowledge.sh` from FlowerCore.Notes — this
   builds + imports the image to all 3 RKE2 nodes with
   FLOWERCORE_DEPLOY_SKIP_ROLLOUT=1 (since the Deployment doesn't
   exist yet on the cluster).
3. Bump the image tag in this manifest to match the freshly-imported
   tag, then `git push` from this repo to land on main. ArgoCD picks
   up within ~3 minutes and creates `infra-knowledge`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:28:26 -05:00
Andrew Stoltz
3e0b9055b0 monitoring: paper-roll lifecycle alerts (XL Track I)
Three new Prometheus alert rules for the print-services group, all routed
to thermal_print via alert_channel label (Grafana contact point ->
irc-notify -> Print.Web /api/print/alert):

- PrintPaperRollLow      (warning, 5-10% remaining, 5m for)
- PrintPaperRollCritical (critical, <=5% remaining, 2m for)
- PrintJobDeadLetter     (warning, any new dead-letter in 15m)

Source-of-truth gauge is print_paper_remaining_percent (Print.Web OTEL),
which is hydrated from the active PaperRoll row at process startup
(Print.Web@<TBD> HydrateMetricsAsync) so the gauge isn't blind for an
arbitrary window after every deploy.

Self-referential humor: low-roll alerts route to the printer that's
running out of paper, so it announces its own paper-out warning on its
remaining paper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:00:40 -05:00
Andrew Stoltz
c828832808 edge2-services: print.iamworkin.lan Traefik HTTPS for Print.Web (XL Track C)
Adds an IngressRoute + cert-manager Certificate that terminates HTTPS for
print.iamworkin.lan and proxies to edge2's Print.Web at 10.0.57.16:5200.

Same headless-Service-with-manual-Endpoints pattern as noc-services (used
for grafana/prometheus/cockpit on noc1). pfSense Unbound already resolves
print.iamworkin.lan to the Traefik VIP 10.0.56.200, so cert-manager
HTTP-01 should validate cleanly.

No basicAuth middleware: Print.Web has its own X-Api-Key authentication
and exposes anonymous endpoints for the bookmarklet / Python CLI /
cups-notifier flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:37:33 -05:00
Andrew Stoltz
e2c71c2b8a fix agent-zero ollama-proxy crashloop + add Longhorn monitoring
agent-zero ollama-proxy had 172 historic restarts (now stable).
Root cause: liveness/readiness probes hit /api/tags which proxies
through to BLUEJAY-WS Ollama (10.0.56.20:11434). When the workstation
Ollama is slow or offline, nginx fails over to the edge1 backup —
but the failover takes >1s and the kube-probe default timeoutSeconds=1
gives up first. Three failed probes → kubelet kills the container.

Fix:
- Add nginx local healthz endpoint (200, no upstream).
- Liveness probe → /healthz (proves nginx itself is alive).
- Readiness probe stays on /api/tags but with timeoutSeconds=5 so
  failover to backup completes before the probe times out.

This decouples liveness from upstream availability — kubelet only
restarts the proxy when nginx is genuinely dead, not when Ollama is
slow.

Longhorn coverage gap: K8s emits "snapshot becomes not ready to use"
events constantly during the hourly snapshot lifecycle (1047
snapshots, all readyToUse=true on inspect). Those events were the
only signal we had — purely transient lifecycle noise, not actionable.

Add:
- longhorn scrape job (longhorn-backend.longhorn-system.svc:9500)
- NetworkPolicy egress rule for longhorn-system port 9500
- 4 new alerts in 'longhorn-storage' group:
  - LonghornVolumeDegraded (>15m) — replica unhealthy, auto-rebuild
  - LonghornVolumeFaulted (>5m, critical, thermal print) — data loss
  - LonghornBackupStale (no completed backup in >36h) — recurring job
    silently failing
  - LonghornNodeUnhealthy (>5m) — node ready=false

zabbix-web 7 restarts and Print.Web 12:55 stop investigated — both
are stable now, no actionable cause found in journal/events. Adding
KubeContainerRestartingFrequently in the previous commit will catch
recurrence of either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:31:14 -05:00
Andrew Stoltz
b3028f5119 monitoring: fix RemoteDesktop pool alerts for stale per-status series
Followup to 05a273d. After deploy, six PoolDepleted/Deficit alerts
went pending again because the publisher emits per-status gauge
series (fc_desktop_pool_depleted{template,status,alert_level}) and
the historical Warming/BelowDesiredSize series stay at value=1 even
after the template transitions to status=Ready. Filtering by
alert_level=Critical/Warning was not enough — those labels are baked
into the stale series too.

Replace with a join-based query: alert only when the canonical
"Ready" status gauge does NOT report ready=1 for the enabled
template. fc_desktop_pool_ready{status="Ready"}==1 is the publisher's
own current-state canary and never goes stale.

Verified against the live cluster — query returns 0 results when all
pools report healthy in their reconcile logs (no stale-label false
positives).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:12:10 -05:00
Andrew Stoltz
05a273d3a6 monitoring: switch K8s scrapes to ClusterIP svc + fix probe paths
Followup to ab6ade4. Three issues uncovered after the rollout:

1. NodePort hairpin breaks scrape from same-node pod. Prometheus on
   rke2-agent1 could reach traefik-metrics on .11/.13 NodePort 30900
   but timed out on its OWN node's NodePort. Same problem would hit
   kube-state-metrics + cert-manager whenever prometheus reschedules.
   Fix: scrape via ClusterIP svc DNS instead of NodePort. NodePorts
   stay in place for external/Podman scrapers.
2. probe-traefik-services failed for grafana, prometheus, guac with
   non-200/3xx codes. grafana + prometheus are behind Traefik basic-
   auth (every endpoint returns 401), so drop from probe surface —
   health is covered by the in-cluster monitoring-* scrape jobs.
   guac.iamworkin.lan was deprecated when Guacamole moved under
   desktop.iamworkin.lan/guacamole/ — drop it.
3. acme path was wrong (root 404). Use /health.

Coverage adds (probe-traefik-services):
chat, dist, dms, menuboard, messageboard, presentations, retail,
ttsreader. All of these have IngressRoutes serving root at 200/3xx.

NetworkPolicy egress rules added so the new ClusterIP svc scrapes work:
- traefik-system: port 9100 (metrics) — separate from data-path 8080/8443
- kube-system: port 8080 (kube-state-metrics)
- cert-manager: port 9402 (controller metrics)

Out-of-band fix during this audit:
- Print.Web on edge2 was inactive (clean exit at 12:55 CDT, root cause
  unclear — systemd Stopping signal). Restarted. Service back on 5200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:05:32 -05:00
Andrew Stoltz
ab6ade4e46 monitoring: stabilize firing alerts + add cluster-state coverage
Live audit on 2026-04-26 found 14 firing alerts caused by stale probe
targets, blackbox TLS verify failures, and stale state-as-label series.
Plus three K8s scrape sources (kube-state-metrics, cert-manager,
traefik) that exposed NodePorts but were not in any scrape config.

Fixes
- probe-remotedesktop: switch http_2xx -> https_internal. Blackbox does
  not trust step-ca root, so /health was failing with x509 unknown
  authority while the app served 200s.
- probe-agentzero-nuc: short svc form (agent-zero.agent-zero.svc:80)
  instead of *.cluster.local. The FQDN form was being rewritten to the
  Traefik VIP by the CoreDNS iamworkin.lan template + ndots:5 search
  expansion, then 5s timeout.
- probe-agentzero-local + probe-ollama-local: removed. 10.0.58.100 is on
  HOME VLAN and not reachable from cluster pods. Workstation/AI-laptop
  Ollama monitoring belongs to host-side Puppet, not cluster blackbox.
- snmp-cloudkey: commented out. The Cloud Key Gen2+ runs unifi-core
  (controller), not an SNMP agent. Was generating "connection refused"
  every 30s.
- RemoteDesktopPoolDepleted / RemoteDesktopPoolDeficitSustained:
  filter on alert_level=Critical / Warning|Critical + enabled=true.
  The publisher emits one series per template per status without
  resetting old series to 0, so the historical Warming/BelowDesiredSize
  series stayed at 1 and the alert kept firing on stale labels.
- RemoteDesktopTlsExpiry: match by job, not hostname-only instance.
  The probe sets instance=https://desktop.iamworkin.lan/health so a
  hostname-only label match never fired.
- EpsonPrinterDown for: 5m -> 30m. EcoTank sleeps after ~5 min idle and
  SNMP times out, so 5m guaranteed nightly noise.

Coverage adds
- kube-state-metrics scrape (NodePort 30901). Required for the new
  pod-state alerts and a long list of standard K8s SLO queries.
- cert-manager scrape (NodePort 30902). Required for the
  CertManagerCertificateNotReady / RenewalFailed alert pair documented
  in project_cert_manager_prometheus_scrape.
- traefik scrape (NodePort 30900) on all three nodes.
- probe-traefik-services: HTTPS probe (https_internal) over the 17 main
  iamworkin.lan hosts so any Traefik-fronted service returning non-200
  shows up as a single named probe failure.
- blackbox-config: add the https_internal module that the new probes
  reference (was only in the FlowerCore.Notes scripts/monitoring copy,
  not in the live ConfigMap).

New alerts (kubernetes-state group)
- KubeContainerRestartingFrequently (>5 restarts/h)
- KubeContainerCrashLooping (>3 restarts/15m, thermal print)
- KubePodNotReady (Pending/Failed/Unknown >15m)
- KubePodImagePullBackOff (>10m)
- KubeDeploymentReplicasMismatch (>15m)

Without these, the agent-zero ollama-proxy 172x restart loop was
invisible for ~3 days. Same gap would have hidden the fc-php
php84-app-probe ImagePullBackOff orphan (cleaned up out of band).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:57:18 -05:00
Andrew Stoltz
4848f72eec fc-telephony: bump web to v202604252156 (T7 step trail) 2026-04-25 21:56:14 -05:00
Andrew Stoltz
f5eafc5def fc-telephony: bump web to v202604252144
Live workflow position tracking + canvas overlay sprint.
- Schema: CallSession.CurrentStep* + CallLog.Step* (migration
  AddCallSessionWorkflowPosition)
- Real-time CallStepExecuted events on every step entry, both
  Asterisk and Twilio paths
- New /calls/{id}/workflow live workflow viewer with visited
  path overlay and pulsing current-step badge
- GET /api/sessions/{id}/path + MCP get_call_session_path
- ActiveCalls 30s -> 3s poll + Live indicator + per-row View
  Workflow link
- Asterisk regroup also rolls in: playback verification,
  fallback chain, MainLayout refresh

Tests: 11525 -> 11549 pass / 1 skip / 0 fail. Build 0E.
Source: master @ 05b3d1c.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:44:14 -05:00
Andrew Stoltz
2d3fd74bab fc-ttsreader: bump web to v202604252002 (alignment Status guard relaxed) 2026-04-25 20:06:26 -05:00
Andrew Stoltz
df4e1f78b0 fc-ttsreader: bump web to v202604251956 (XL: per-chapter annotate + word-level alignment + Study TOC + Resume row) 2026-04-25 19:59:56 -05:00
Andrew Stoltz
2a10b775a8 fc-ttsreader: bump web to v202604251935 (Slice 5: select-to-annotate pronunciation + mood) 2026-04-25 19:39:27 -05:00
Andrew Stoltz
447ddd339d fc-ttsreader: bump web to v202604251917 (Bible passage shorthand: 'Esther 1' etc.) 2026-04-25 19:21:00 -05:00
Andrew Stoltz
7833143c1c fc-ttsreader: bump web to v202604251903 (Slices 2/3/4 + Lane I MCP + Lane J pills) 2026-04-25 19:08:08 -05:00
Andrew Stoltz
8ed77c4627 fc-ttsreader: bump web to v202604251836 (seek-race + auto-scroll + active-cue contrast) 2026-04-25 18:41:17 -05:00
Andrew Stoltz
437f346aee fc-ttsreader: register ttsreader-modern Deployment + Service
Adds the Deployment + Service for the fc-modern-tts container that
landed in the previous commit. Same shape as ttsreader-biblical:
runAsNonRoot uid 1654, dnsPolicy: None to bypass the iamworkin.lan
hijack on Microsoft endpoint lookups, /health probes, modest CPU/mem
since edge-tts is network-bound.

Service surfaces ttsreader-modern.fc-ttsreader.svc:10403 for the web
pod to call when the operator picks a he-IL-* or el-GR-* voice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:39:58 -05:00
Andrew Stoltz
bc32b5ef04 fc-ttsreader: deploy fc-modern-tts (Edge Read Aloud Hebrew/Greek)
Adds a fourth TTS engine alongside Piper / Kokoro / biblical-tts: a
small FastAPI bridge to Microsoft Edge's Read Aloud TTS via the
edge-tts Python package. Provides studio-quality Modern Hebrew (he-IL)
and Modern Greek (el-GR) narrators for the cluster.

modern-tts/Dockerfile + app.py:
- Python 3.12 base + edge-tts==7.2.8 (older versions hit 403 from MS).
- POST /tts -> MP3 audio (audio/mpeg).
- POST /timings -> word-level timings. Edge sometimes omits WordBoundary
  events for non-English voices; fall back to MP3-frame-walking duration
  estimate + proportional distribution across whitespace-split words
  (same approach biblical-tts uses for eSpeak).
- GET /voices?language=all|default — filtered to he-/el- by default so
  the AiStation voice picker isn't overwhelmed by 400+ voices.
- GET /health for probes.
- Body shape mirrors BiblicalTtsRequest so the .NET client lives in the
  same FlowerCore.Shared.Speech package.

K8s deployment in fc-ttsreader namespace:
- ttsreader-modern Deployment + Service on port 10403.
- localhost/fc-modern-tts:v1, imagePullPolicy: Never (built on noc1,
  imported to all 3 RKE2 nodes via ctr).
- runAsNonRoot uid 1654 + fsGroup 1654.
- dnsPolicy: None to bypass the *.iamworkin.lan template hijack on
  Microsoft endpoint lookups.
- Modest resources (100m/128Mi req, 1000m/512Mi limit) — edge-tts is
  network-bound, not compute-bound.
- Probes against /health.

Verified live locally: container handles 'Καλημέρα Ελλάδα Πώς είστε'
in 2496ms, returns el-GR-NestorasNeural voice + 4 word timings.
Hebrew: 'בְּרֵאשִׁית בָּרָא אֱלֹהִים' returns he-IL-AvriNeural,
2472ms, 3 words.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:39:21 -05:00
Andrew Stoltz
263d06acb9 fc-ttsreader: bump web to v202604251750 (Lane H Slice 1: inline Study view + chapter notes) 2026-04-25 17:54:09 -05:00
Andrew Stoltz
25dbb2967f fc-ttsreader: bump web to v202604251714 (BuildRenderPlan splits each mood block via SpeechSentenceSegmenter) 2026-04-25 17:18:09 -05:00
Andrew Stoltz
a89a774eaf fc-ttsreader: deploy eSpeak-NG biblical-tts (Ancient Greek + Hebrew)
Adds a third TTS engine alongside Piper (modern English/multi-lang) and
Kokoro (high-quality English): a small FastAPI wrapper around eSpeak-NG
with built-in support for Ancient Greek (grc), Hebrew (he), and Modern
Greek (el). Same shape as fc-speech-align so AiStation talks to all the
TTS/alignment services with one HTTP client pattern.

biblical-tts/Dockerfile + app.py:
- Python 3.12 base + apt-get espeak-ng + libsndfile1 + ffmpeg-free deps.
- POST /tts -> WAV audio bytes (audio/wav).
- POST /timings -> word-level timings derived from espeak's --pho phoneme
  duration stream, distributed across whitespace-split words proportional
  to character count. Accuracy is good enough for chip-level read-along
  highlighting (~30-80ms per-word jitter).
- GET /voices for catalog discovery, GET /health for probes.
- Body shape mirrors AlignmentRequest from FlowerCore.Shared.Speech so
  the .NET BiblicalTtsClient round-trips it cleanly.

K8s deployment in fc-ttsreader namespace:
- ttsreader-biblical Deployment + Service on port 10402.
- localhost/fc-biblical-tts:v1, imagePullPolicy: Never (built on noc1,
  imported to all 3 RKE2 nodes via ctr).
- runAsNonRoot uid 1654 to match the namespace's standard security ctx.
- Modest resources (100m/128Mi req, 1000m/512Mi limit) — eSpeak is
  CPU-cheap.
- Probes hit /health which returns the supported language list.

Verified live: container started, /health returns ok with grc/el/he,
POST /timings on Ἐν ἀρχῇ ἦν ὁ λόγος returned 5 words / 1714ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:17:38 -05:00
Andrew Stoltz
dc39747f3f fc-ttsreader: piper memory 1Gi -> 3Gi to stop OOMKill mid-render 2026-04-25 17:10:20 -05:00
Andrew Stoltz
87050e72a9 fc-ttsreader: deploy Kokoro to the cluster (replaces BLUEJAY-WS host pointer)
The cluster ttsreader-web was reaching across to BLUEJAY-WS:10401 for
Kokoro synthesis, which meant a workstation-down event broke render-
pipeline TTS. Add a cluster-native ttsreader-kokoro Deployment and
Service inside fc-ttsreader so the cluster owns the engine.

- Image: ghcr.io/remsky/kokoro-fastapi-cpu:latest. Model + 67 voices
  ship inside the image, so no PVC is required.
- Port 8880 (the kokoro-fastapi default; the entrypoint hardcodes it).
- Resources: 250m/1Gi request, 2000m/3Gi limit. CPU-only inference
  matches what AiStation runs locally on BLUEJAY-WS.
- dnsPolicy: None to bypass CoreDNS's *.iamworkin.lan template hijack
  on huggingface.co lookups, same shape as ttsreader-align.
- Probes hit /v1/audio/voices since the kokoro server doesn't expose
  /health; that endpoint is cheap (lists configured voice files).

ttsreader-web env var TtsReader__Kokoro__BaseUrl flips from the
workstation pointer to the cluster service:
http://ttsreader-kokoro.fc-ttsreader.svc.cluster.local.:8880.

AiStation keeps its local http://localhost:8880 since the workstation
operator still wants the audio to render on the local sound device
without a network hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:56:39 -05:00
Andrew Stoltz
e8c5d2afd2 fc-ttsreader: bump web to v202604251544 (Unicode sanitize + continue-on-segment-fail) 2026-04-25 15:50:16 -05:00
Andrew Stoltz
eef492125f fc-ttsreader: bump web to v202604251534 (SignalR 8MB + DOM-peek + poll loop refresh fix) 2026-04-25 15:39:18 -05:00
Andrew Stoltz
b51ee35bfa fc-speech-align: v3 — emit FlowerCore.Shared.Speech word contract
The /align endpoint was returning Whisper-native word fields
(word/startSeconds/endSeconds/confidence), but FlowerCore.Shared.Speech's
FasterWhisperAlignmentClient on master deserializes
FasterWhisperWord against [JsonPropertyName("text")/("startMs")/("endMs")].
Result: ttsreader-web reported alignment.source="whisper" with words[]
present but every entry had Text="" and StartMs=EndMs=0 — visible in the
2026-04-25 hello-world smoke against ttsreader.iamworkin.lan.

Match the published Common contract instead of the Python model's native
shape: emit text/startMs/endMs (millisecond ints, not float seconds).
Confidence stays on the wire as informational; the deployed C# client
ignores it but a future fc-align operator UI can surface low-confidence
words. Bump tag to v3 and bump the Deployment image accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:52:14 -05:00
Andrew Stoltz
4abc2fa95d fc-speech-align: add dnsPolicy: None to bypass CoreDNS *.iamworkin.lan template hijack on huggingface.co 2026-04-25 11:12:21 -05:00
Andrew Stoltz
d7628a6945 fc-speech-align: bump to v2 with explicit requests dep (faster-whisper 1.0.3 missing transitive) 2026-04-25 10:55:51 -05:00
Andrew Stoltz
df115e4d1e fc-ttsreader: ship cluster-native fc-speech-align (faster-whisper) + bump web
- New ttsreader-align Deployment + Service + 5Gi PVC under
  apps/fc-ttsreader/. Wraps SYSTRAN/faster-whisper in a small FastAPI app
  exposing POST /align (fc-align contract used by Shared.Speech) AND
  POST /transcribe (audio-in feature consumed by ttsreader-web Lane G).
  Source: apps/fc-ttsreader/speech-align/ (Dockerfile + app.py +
  requirements.txt). Built locally (apt-get RUN steps need BLUEJAY-WS,
  not noc1) and ctr-imported to all 3 RKE2 nodes.
- ttsreader-web env: flip Speech__Alignment__Enabled=true and point
  BaseUrl at http://ttsreader-align.fc-ttsreader.svc.cluster.local.:9200.
  Add new TtsReader__Transcription__* env triplet pointing at the same
  service (same /transcribe endpoint).
- Bump ttsreader-web image to v202604251046 (carries the
  TranscriptionController + MCP tool + Quick.razor InputFile UI).
2026-04-25 10:50:45 -05:00
Andrew Stoltz
9df26620b8 fc-ttsreader: disable Whisper, fall back to estimator until backend is reachable
The cluster-wide pod cannot reach BLUEJAY-WS speaches on 10.0.56.20:9200
because the rootless+host-net podman setup binds 127.0.0.1 only on the
WSL machine; nothing on the LAN-facing interface. The openai-compatible
Backend value also relied on a Common change still on feat/shared-indexing
rather than master, so the deployed image's Shared.Speech only knows
the FC-native /align shape.

Disable Speech:Alignment for now. EstimatedAlignmentClient kicks in and
keeps /api/v1/voices/preview-with-timings returning word-aligned JSON,
just with uniform-distribution timings instead of real Whisper output.

Re-enable once: (a) Common's openai-compatible Backend lands on master
and a new TtsReader image ships, or (b) we point at a LAN-routable
backend (e.g. an aiohttp /align shim, or speaches running on a node
that's actually reachable from cluster pods).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:28:21 -05:00
Andrew Stoltz
08aa7a5bff fc-ttsreader: route Whisper alignment at openai-compatible backend
The fc-speech-align container on BLUEJAY-WS (port 9200) is the speaches
build of faster-whisper-server, which exposes the OpenAI-compatible
/v1/audio/transcriptions contract — not the FlowerCore /align contract.

FasterWhisperAlignmentClient (FlowerCore.Common a1b3bfc) supports both
shapes; tell it explicitly to talk OpenAI-compatible here so requests land
on the right endpoint and verbose_json gets adapted into the FC alignment
response. Also pin the Model id to one speaches recognizes.

Switch back to fc-align once a native /align backend is deployed (or wire
a tiny FastAPI shim in front of speaches if we want a stable contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:25:24 -05:00
Andrew Stoltz
38e20a8b64 fc-ttsreader: bump to v202604251018 (resume banner + scope-ID rebuild fix) 2026-04-25 10:24:39 -05:00
Andrew Stoltz
c945d44b9e fc-ttsreader: bump to v202604250729 (playback bookmark + breaker fix) 2026-04-25 07:33:38 -05:00
Andrew Stoltz
1f1354f634 fc-intranet-web: bump to v202604242354overridefix 2026-04-24 23:57:18 -05:00
Andrew Stoltz
76ece92cfd fc-ttsreader: enable real Whisper alignment via fc-speech-align
Flips Speech__Alignment__Enabled=true and points BaseUrl at the
new BLUEJAY-WS podman quadlet running fc-speech-align (faster-
whisper, /align contract). When Lane 1δ's
/api/v1/voices/preview-with-timings runs after this lands, the
alignment.source field flips from 'estimated' to 'whisper' and
the per-word timings come from real audio analysis instead of
uniform-spacing estimates.

No image rebuild — the Lane 1α DI registration already routes
IWhisperAlignmentClient to FasterWhisperAlignmentClient when
Speech:Alignment:Enabled is true.

Companion firewall rule from FlowerCore.Puppet@bbc02ea +
@05504ed (whisper_align_enabled flag on bluejay-ws-linux Hiera)
opens port 9200 to RKE2 pod CIDR durably.
2026-04-24 23:37:30 -05:00
Andrew Stoltz
a760a58846 fc-intranet-web: bump to v202604242315wordhighlight (Lane 1γ.1)
Word-level highlighting + inline annotation popover in the
read-aloud bar, backed by TtsReader's preview-with-timings
(Lane 1δ) and the existing /api/v1/pages/{encodedUrl}/overrides
REST surface (Lane 2α).

Built from FlowerCore.Intranet.Web@9abde21 against
FlowerCore.Common@d23d4c3, both on master.
2026-04-24 23:21:59 -05:00
Andrew Stoltz
9fb526c7c5 fc-ttsreader: bump to v202604242301readwithtimings
Picks up Lane 1δ + 3β:
- Lane 3β: GET /reader-embed iframe-friendly host route + public-
  read CORS for /api/v1/voices and /_content/.../embed/
- Lane 1δ: GET /api/v1/voices/preview-with-timings — pairs
  synthesized audio with per-word alignment timings (faster-
  whisper or estimated fallback) so embed bundle / FcReaderOverlay
  / in-app /voices preview can word-highlight in one round-trip
- Latest FlowerCore.UI.Components from Common master:
  FcReaderOverlay annotation popover (Lane 2γ) + <fc-reader>
  standalone embed bundle (Phase 3)

Built from FlowerCore.TtsReader@06ef815 (master) against
FlowerCore.Common@d23d4c3 (master).
Image imported on rke2-server / rke2-agent1 / rke2-agent2.
2026-04-24 23:05:41 -05:00
Andrew Stoltz
dd7980642e fc-intranet-web: bump to v202604242222readaloud
Picks up the merged Lane 1γ + Lane 2α + Lane 1β + Phase 3 work:
top-bar Read aloud button + per-page reading overrides REST +
FcReaderOverlay shared component + <fc-reader> embed bundle.

Built from FlowerCore.Intranet.Web@35a552f against
FlowerCore.Common@a56975a, both on master.
Image imported on rke2-server / rke2-agent1 / rke2-agent2.
2026-04-24 22:26:12 -05:00
Andrew Stoltz
1d4ad64226 chore(ttsreader): bump to v202604241555backlog (rebuild with correct publish/)
Previous v202604241543backlog image accidentally used a stale
publish/ directory at the TtsReader repo root (Dockerfile.deploy
says COPY publish/ but my ad-hoc publish wrote to artifacts/publish/).
Rebuilt with a clean copy from artifacts/publish/ to publish/ first.

Confirmed new image has appsettings.json Preview section + the
quick-swipe-gestures.js asset baked in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:53:55 -05:00
Andrew Stoltz
774f82c431 chore(ttsreader): bump image to v202604241543backlog
Merges three parallel backlog lanes onto longsegfix:
- B (hotfix/preview-timeout-2026-04-24): 25s preview-path timeout, 504 on
  expiry, config-tunable via TtsReader:Preview:TimeoutSeconds.
- C (feature/swipe-gestures-2026-04-24): PointerEvent swipe gestures on
  /quick (prev/next sentence) + CSS fade-hint.
- D (feature/bible-defaults-button-2026-04-24): Apply Bible speech
  defaults button on project detail page with confirm + toast.

TtsReader master octopus merge: 730e7fa. Tests 155/155 .NET + 13/13 JS.
28 MCP tools. Built + imported on all 3 RKE2 nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:47:13 -05:00
Andrew Stoltz
d2cc36ea0e chore(ttsreader): bump image to v202604241345longsegfix
Hotfix for two live render errors:
- Kokoro chapter render failed with "count ('-1') must be non-negative"
  — streaming-WAV chunk-size sentinel (0xFFFFFFFF) read as -1.
- Piper render timed out on book-chapter paragraphs with no sentence
  punctuation — one giant segment exceeded the 2-min timeout.

Source fix: FlowerCore.TtsReader@826589b. 153/153 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:58:45 -05:00
Andrew Stoltz
299070e4bf fc-ttsreader: bump to v202604241332 (XL sprint — sidebar shell + CHAP + offline ZIP + breaker)
Merges four XL-sprint lanes to TtsReader master:
- FcSidebarLayout swap (MainLayout + NavMenu, deletes 88-line shell CSS)
- ID3 CHAP chapter markers in rendered MP3s (Apple Podcasts chapter list)
- POST /api/v1/projects/{id}/export/zip offline bundle + MCP tool
- Per-engine TtsCircuitBreaker with Kokoro→Piper fallback + /voices
  engine-health panel + GET /api/v1/voices/engines/status

153/153 tests green on Linux dotnet build. Image imported to
rke2-server / rke2-agent1 / rke2-agent2. No app env changes beyond
the image tag.
2026-04-24 13:37:33 -05:00
Andrew Stoltz
a9debd8668 fix(guacamole): remove .notification button::before override that clipped native action-icon sprite
Guacamole 1.6 renders .button.home/.button.logout/.button.reconnect icons
via an absolutely-positioned ::before pseudo-element with width:1.8em
and background-position:.5em .45em. The Blue Jay branding CSS was
clamping every .notification button::before to display:inline-flex
and width:1rem, so only the top-left sliver of the sprite rendered —
appearing as a green/purple garbage rectangle on the connection-not-found
page Home button. Reconnect escaped because it doesn't carry a
background-image on its ::before. The rule was redundant anyway: the
.notification button flex row + padding already spaces the native icon
cleanly. Only the custom .fc-embed-logout-disabled::before override
remains (intentionally dims the replacement disabled-logout pseudo-element).
2026-04-24 11:23:46 -05:00
Andrew Stoltz
675b9da4f9 intranet: bump to v202604240144longchunk2 (tightened chunk cap)
v202604240140longchunk still hit 400 Bad Request from nomic-embed-text
on several batches — the chars/4 token estimate was optimistic for
code-heavy/Unicode content. Rebuilt from FlowerCore.Common@e1c28b4
which tightens MarkdownChunker hard cap (ChunkSizeTokens × 2, clamped
at 16000 chars) AND adds a character-length check in IndexBuilder's
safety filter alongside the estimated-tokens check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:44:58 -05:00
Andrew Stoltz
2b471a55b0 intranet: bump to v202604240140longchunk (rebuild with correct corpus)
v202604240135longchunk image shipped with only 1 file in the baked
corpus (NEXT-SPRINT.md) because the corpus tar was accidentally built
from the Intranet.Web working directory instead of the Notes repo
root. Rebuilt from the right cwd; new image has the expected 370
*.md + *.html files at /srv/flowercore-notes/docs/.

Same long-chunk handling code as v202604240135longchunk; just a clean
rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:40:49 -05:00
Andrew Stoltz
37ce0aed85 intranet: v202604240135longchunk — long-chunk handling fix
Image bump v202604240108gpu -> v202604240135longchunk, rebuilt from
FlowerCore.Intranet.Web@feat/shared-indexing-search HEAD which transitively
picks up FlowerCore.Common@feat/shared-indexing@105af75:

- MarkdownChunker hard-caps oversized heading-bounded sections at
  ChunkSizeTokens × 4 chars and splits with overlap (same pattern as
  JsonArticleChunker). Stops the indexer from producing chunks above
  nomic-embed-text's 8192-token input limit at the source.

- IndexBuilder gains IndexingOptions.MaxEmbeddingTokens (default 8000)
  safety filter — chunks above the cap are warn-logged and dropped
  before any batch is sent. New IndexBuildResult.ChunksDropped tracks
  how many got skipped.

Goal: notes-md should index 2541/2541 chunks (vs. 2080/2541 last pass)
with zero "Failed to embed batch" 400s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:28:00 -05:00
Andrew Stoltz
a37fc83584 ttsreader: bump to v202604240117 (fix blazor-error-ui scope drift) 2026-04-24 01:21:38 -05:00
Andrew Stoltz
3a8aae9e2d chore(guacamole): retire legacy guac.iamworkin.lan IngressRoute+cert
Single-host routing via desktop.iamworkin.lan/guacamole has been
live-proven (curl → 200) and the Codex single-host-guacamole-wip
merge flipped RemoteDesktop.Web's GuacamolePublicUrl + defaults to
the new path. Nothing else in FlowerCore actively requires the
legacy guac.iamworkin.lan URL.

Removed from the guacamole app:
- IngressRoute `guacamole` matching Host(guac.iamworkin.lan)
- Middleware `guac-add-prefix` (only the legacy route referenced it)
- Certificate `guacamole-tls` (only covered guac.iamworkin.lan)

ArgoCD prune will delete the live resources on next sync. The
pfSense DNS override for guac.iamworkin.lan should be removed
via FlowerCore.DNS as a follow-up operator step — not managed by
this repo.

The new `guacamole-desktop-path` IngressRoute + `desktop-guacamole-path-tls`
Certificate (added in e65de29) handle all Guacamole traffic going
forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:14:25 -05:00
Andrew Stoltz
020a806d08 intranet: v202604240108gpu — point indexer at BLUEJAY-WS GPU + FilePatterns fix
Two-part fix on top of the live Shared.Indexing rollout:

1. Image bump v202604240050corpus -> v202604240108gpu, rebuilt from
   FlowerCore.Intranet.Web@feat/shared-indexing-search (HEAD includes
   the FilePatterns array-merge fix in IntranetSearchOptions). At
   runtime each DocCorpusRoot now sees ONLY the patterns explicitly
   set in appsettings.json — notes-md gets ["*.md"], notes-html gets
   ["*.html"], no accidental cross-bleed.

2. New IntranetSearch__OllamaBaseUrl env var pointing at
   http://10.0.56.20:11434 (BLUEJAY-WS GPU, R9700 32GB VRAM). Verified
   reachable from the cluster and nomic-embed-text:latest is pulled.
   This is the workaround for memory feedback_pi5_nomic_embed_slow:
   edge1 Pi 5 takes ~189s per 32-chunk batch, projecting full notes-md
   indexing (5665 chunks) at ~9 hours; the GPU should land it in minutes.
   Edge1 stays the chat default; this env var only redirects the
   indexer's bulk embedding calls.

Image distributed to all three RKE2 nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:08:55 -05:00
Andrew Stoltz
e65de2938b feat(ingress): single-host Guacamole via guacamole-namespace IngressRoute
Cluster Traefik disallows cross-namespace service refs from
IngressRoutes, so the PathPrefix(/guacamole) rule I added to
fc-desktop IngressRoute in 292528e failed with:

  "service guacamole/guacamole not in the parent resource namespace
  fc-desktop"

Move the /guacamole path match into the guacamole namespace where
the Service actually lives:

- apps/guacamole/guacamole.yaml adds a new `guacamole-desktop-path`
  IngressRoute matching `Host(desktop.iamworkin.lan) &&
  PathPrefix(/guacamole)` → guacamole:8080 (no add-prefix middleware;
  the browser already sends the /guacamole/* path that Guacamole's
  servlet serves at).
- New Certificate `desktop-guacamole-path-tls` for desktop.iamworkin.lan
  in the guacamole namespace, issued by step-ca-acme. Separate cert
  from fc-desktop's remotedesktop-web-tls because Secret refs are
  also scoped per-namespace; duplicating the cert is cheaper than
  enabling cross-namespace secret refs cluster-wide.
- Revert the cross-namespace attempt in apps/fc-desktop/fc-desktop.yaml
  back to a Host-only route. Traefik's router matching precedence
  (longer/more-specific rule wins) handles the /guacamole vs
  catch-all priority without explicit priority: fields.

Closes the single-host Guacamole URL regression Codex's branch
introduced — GuacamolePublicUrl=https://desktop.iamworkin.lan/guacamole
now resolves to the Guacamole webapp end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:07:12 -05:00
Andrew Stoltz
5c0c21790e ttsreader: bump image to v202604240101 (Kokoro voices merge fix) 2026-04-24 01:05:55 -05:00
Andrew Stoltz
292528ec15 feat(fc-desktop): add /guacamole PathPrefix route to IngressRoute
Single-host Guacamole routing — Traefik matches Host=desktop.iamworkin.lan
+ PathPrefix=/guacamole first (priority 20) and forwards to the
guacamole Service in the guacamole namespace on 8080. The existing
Host-only catch-all rule drops to priority 10 so Guacamole traffic
resolves to the more-specific match.

Mirrors the IngressRoute in FlowerCore.RemoteDesktop@master (merged
as part of codex/single-host-guacamole-wip). The RemoteDesktop repo
copy is deploy-ref only — ArgoCD owns the live IngressRoute via
this manifest. Without this change, GuacamolePublicUrl=
https://desktop.iamworkin.lan/guacamole returns 404 because Traefik
routes the whole Host to remotedesktop-web.

Unblocks the per-template AAT smoke against the new public URL
path + closes the final live piece of Codex's single-host routing
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:03:34 -05:00
Andrew Stoltz
bb39a0c1fd ttsreader: bump image to v202604240053 + Kokoro env vars
Adds TtsReader__Kokoro__Enabled=true + BaseUrl=http://10.0.56.20:10401
+ TimeoutSeconds=120 so the pod routes kokoro-tagged voices to the
Kokoro-FastAPI backend running on BLUEJAY-WS. Multi-engine router
falls through to Piper for piper-tagged and untagged voices.

Requires nftables on BLUEJAY-WS to permit tcp/10401 from 10.0.56/23
and 10.42.0.0/16. Applied to the live ruleset — Puppet Hiera path is
the durable fix (kokoro_server_enabled under profile::security::firewall).

Tests 107 → 114 (+7 MultiEngineSpeechSynthesizerTests).
2026-04-24 00:57:41 -05:00
Andrew Stoltz
c23e903ba7 feat(monitoring): Grafana alert rules route RemoteDesktop to IRC
Companion to the Prometheus alert rules landed in e44e9a0. The
Prometheus rules were loading but never delivered — the monitoring
stack has no Alertmanager configured; **Grafana** owns alert
routing via its built-in engine + webhook contact point to
irc-notify.monitoring.svc:9119. Without a matching Grafana alert,
the Prometheus rules just show up in the Prometheus UI and page
no one.

Adds 6 Grafana alert rules in a new `RemoteDesktop` group under
the AI Stack Alerts folder:

- remotedesktop-web-down (3m) — probe_success{job="probe-remotedesktop"} < 1
- remotedesktop-metrics-stale (10m) — fc_desktop_session_events_total series absent
- remotedesktop-pool-depleted (5m) — fc_desktop_pool_depleted > 0
- remotedesktop-pool-deficit-sustained (10m info) — fc_desktop_pool_deficit > 0
- remotedesktop-session-churn-spike (5m info) — launch rate > 20/min
- remotedesktop-tls-expiry (6h critical) — cert < 2 days to expiry

Each uses the standard Grafana 3-stage pipeline (query → reduce →
threshold) matching the existing AI Stack + Infrastructure alert
patterns. Labels: service=remotedesktop + severity (warning/info/critical).
Default route is `IRC #alerts` via the existing webhook contact point.

Parity with the Prometheus rules (which already fire internally
for the Prometheus UI + any future Alertmanager integration).
Grafana restart picks up the new provisioning on next reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:57:26 -05:00
Andrew Stoltz
cae03296f5 intranet: bake Notes corpus into image, drop init container
Cluster egress to github.com is fronted by a step-ca TLS proxy that
returns 404 page not found for unmatched routes — git clone of the
public FlowerCore.Notes repo failed inside the pod even with
GIT_SSL_NO_VERIFY=true. Rather than chase the egress NetworkPolicy /
proxy config, bake the docs corpus directly into the image at
/srv/flowercore-notes/docs.

The corpus is just *.md + *.html (369 files, 2.7 MB uncompressed) —
small enough that re-baking on every deploy is fine and avoids any
runtime network dependency.

Manifest changes:
- Image bump: v202604240040search -> v202604240050corpus
- Removed initContainers (clone-notes-corpus is now redundant)
- Removed notes-corpus emptyDir + its volumeMounts
- Vector-store PVC mount stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:50:00 -05:00
Andrew Stoltz
3c5c1a07bd fix(monitoring): netpol egress allows for fc-desktop + Traefik hairpin
Adds two egress allows to monitoring-netpol so Prometheus can scrape
FlowerCore.RemoteDesktop:

1. fc-desktop namespace on port 8080 — direct ClusterIP service
   target (remotedesktop-web.fc-desktop:8080).
2. traefik-system namespace pods on ports 8080 + 8443 — covers the
   Traefik VIP hairpin path for the `https://desktop.iamworkin.lan`
   scrape target (CoreDNS wildcard resolves iamworkin.lan hostnames
   to the LB VIP; after kube-proxy DNAT, egress needs the backend
   pod port allowed per feedback_netpol_dnat_backend_port).

Without these, the fc-remotedesktop scrape times out with "context
deadline exceeded" even though the monitoring-netpol already allows
the 10.0.56.0/24 CIDR — post-DNAT the destination is a 10.42.x.x
pod IP, not the VIP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:47:50 -05:00
Andrew Stoltz
057595de3d intranet: GIT_SSL_NO_VERIFY=true in clone-notes-corpus init container
Cluster egress is fronted by a step-ca TLS proxy whose cert doesn't
match github.com. The init container's git clone failed with
"SSL: no alternative certificate subject name matches target hostname
'github.com'". The Notes repo is public — there is no secret to
protect on the wire — so GIT_SSL_NO_VERIFY=true is the right tradeoff
here. Tag at v202604240040search.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:46:20 -05:00
Andrew Stoltz
b02bb4be38 intranet: deploy v202604240040search with Notes corpus + vector store
Phase 3 lane 1 of FlowerCore.Shared.Indexing rollout — wires the new
search consumer in FlowerCore.Intranet.Web to live infrastructure.

Manifest changes:
- Image bump: localhost/fc-intranet-web:latest -> :v202604240040search.
  Built from FlowerCore.Intranet.Web@feat/shared-indexing-search and
  imported into all three RKE2 nodes (rke2-server, rke2-agent1, rke2-agent2)
  via ctr import. Both :latest and :v202604240040search tags are present.
- New PersistentVolumeClaim intranet-vector-store (1Gi, ReadWriteOnce,
  Longhorn) mounted at /data for the SQLite vector store
  (intranet-vectors.db).
- New emptyDir volume notes-corpus (1Gi sizeLimit) shared between the
  init container and main container, mounted at /srv/flowercore-notes
  (read-only in the main container).
- New init container clone-notes-corpus (alpine/git) that shallow-clones
  https://github.com/astoltz/FlowerCore.Notes.git
  (codex/notes-pimanager-live-drift) into /srv/flowercore-notes on every
  pod start. Re-clone is cheap (depth=1) and re-runs of git fetch +
  reset --hard are idempotent.
- Strategy switched to Recreate for the deployment, since the new RWO
  PVC blocks rolling updates — see CLAUDE.md memory "RWO PVC blocks K8s
  rolling updates".
- Resource bumps: memory 128Mi -> 256Mi req, 512Mi -> 1Gi limit; CPU
  500m -> 1000m limit. The DocsCorpusIndexer + Ollama HTTP calls add
  measurable load during the initial index build.
- initialDelaySeconds bumps on both probes (10s -> 30s liveness, 5s ->
  10s readiness) to account for startup-time Ollama probing and the
  slightly larger image.

The DocsCorpusIndexer waits 15s after host startup before its first
indexing pass, then loops every RescanInterval (default 1h). Its first
run will:
1. Embed all *.md under /srv/flowercore-notes/docs against
   nomic-embed-text on edge1 (10.0.57.17:11434).
2. Embed all *.html under /srv/flowercore-notes/docs/dashboards.
3. Persist chunks + embeddings to /data/intranet-vectors.db.

Verify after rollout:
- kubectl -n intranet logs deploy/intranet-web -c clone-notes-corpus
  (init container should show the docs/ listing).
- kubectl -n intranet logs deploy/intranet-web -f
  (DocsCorpusIndexer should log "Indexing docs root 'notes-md'..." then
  "Docs root 'notes-md' indexed: N files, M chunks, M stored").
- curl -sk https://intranet.iamworkin.lan/api/search/indexes
  -> ["notes-html","notes-md"]
- curl -sk 'https://intranet.iamworkin.lan/api/search?q=guacamole+single+host&topK=3'
  -> hits from docs/infrastructure/guacamole-customization-plan.md

Companion source on FlowerCore.Intranet.Web@feat/shared-indexing-search.
Depends on FlowerCore.Common@feat/shared-indexing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:42:03 -05:00
Andrew Stoltz
e44e9a0062 feat(monitoring): RemoteDesktop alerts + scrape jobs + dashboard mount
Three additions to the monitoring ConfigMap, each targeting
FlowerCore.RemoteDesktop:

- **Scrape jobs** (2 new):
  - probe-remotedesktop: blackbox http_2xx against
    https://desktop.iamworkin.lan/health every 30s. Feeds the
    RemoteDesktopWebDown alert.
  - fc-remotedesktop: direct /metrics scrape against
    desktop.iamworkin.lan for the fc_desktop_session_events_total
    and fc_desktop_pool_* series.

- **Alert group `remote-desktop`** (7 rules in alerts.yml):
  - RemoteDesktopWebDown (3m) — /health probe failing
  - RemoteDesktopMetricsStale (10m) — absent metrics series
  - RemoteDesktopPoolDepleted (5m) — pool deficit + depleted flag
  - RemoteDesktopPoolDeficitSustained (10m, info) — persistent
    below-desired pool size
  - RemoteDesktopSessionChurnSpike (5m, info) — launch rate
    >20/min
  - RemoteDesktopRecordingEventsDropped (15m, info) — 30m without
    recording events while launches active
  - RemoteDesktopTlsExpiry (6h, critical) — <2d cert renewal
    window; aligns with feedback_acme_expiry_alert_threshold

- **Grafana dashboard mount**: new volumeMounts + volumes entry for
  `dashboards-remotedesktop` backed by the grafana-dashboard-remotedesktop
  ConfigMap (previously added as a standalone file in d4210c8).
  Folder path /var/lib/grafana/dashboards/remotedesktop — picked up
  by the file-provider with foldersFromFilesStructure:true so the
  dashboard shows up in a "Remotedesktop" folder in Grafana.

No CRLF churn; pure 100-line insertion into LF-normalized file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:41:35 -05:00
Andrew Stoltz
297a2a9bbc ttsreader: bump image to v202604240023 for P3 one-click book render
New surfaces: POST /api/v1/bible/projects (one-click whole-book render),
GET /api/v1/bible/books, GET /api/v1/bible/books/{book}/preview, MCP
tools render_tts_reader_bible_book + list_tts_reader_bible_books,
Dashboard "Render a Bible book" card. 107/107 tests, +7 from previous.
2026-04-24 00:25:48 -05:00
Andrew Stoltz
d4210c819f feat(monitoring): RemoteDesktop Grafana dashboard ConfigMap
Wraps apps/monitoring/flowercore-remotedesktop-grafana-dashboard.json
as a ConfigMap manifest so ArgoCD syncs it into the cluster alongside
the existing grafana-dashboard-* ConfigMaps. Standalone file — does
NOT modify noc-monitoring.yaml. That keeps the CRLF churn on
noc-monitoring.yaml (sibling files apps/intranet/intranet.yaml and
apps/agent-zero/configmaps-bluejay.yaml also carry CRLF churn) out
of this commit.

Dashboard will be synced into the cluster but NOT loaded by Grafana
until a matching `volumes:` entry lands in the Grafana Deployment
in noc-monitoring.yaml:

    - name: dashboard-remotedesktop
      configMap:
        name: grafana-dashboard-remotedesktop

Plus a `volumeMounts:` entry in the grafana container:

    - name: dashboard-remotedesktop
      mountPath: /etc/grafana/provisioning/dashboards/remotedesktop
      readOnly: true

Those edits are deferred to the CRLF-normalization pass on
bluejay-infra so the review diff stays reviewable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:20:40 -05:00
Andrew Stoltz
fc0b67f670 ttsreader: bump image to v202604232334 for iTunes RSS + ID3 tags
Pulls in FlowerCore.TtsReader@9e2497f: P2.3 iTunes-namespace podcast
feed (author, summary, category, cover art, episode numbering,
duration, atom:self link, serial channel type for Bible projects) and
P2.4 ID3v2 tags on MP3 export + Vorbis comments on OGG (title, artist
with Piper voice humanized, album, track N/M, genre defaulting to
Religion & Spirituality for Bible or Audiobook for text sources,
date). Phones and podcast apps now show proper track info instead of
"Unknown - Unknown".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:37:53 -05:00
Andrew Stoltz
223e9a9232 feat(zabbix): add RemoteDesktop monitoring template
New Zabbix 7.2 template under `Templates/FlowerCore` that scrapes
the `/metrics` exposition from FlowerCore.RemoteDesktop and extracts:

- `fc_desktop_session_events_total` split by event (launch/connect/
  disconnect/recording), with a dedicated datapoint for the
  `browser_datasource="json"` slice to track delegated-auth launches.
- `fc_desktop_pool_ready` gauge sum for warm pools.

Trigger: `nodata(flowercore.remotedesktop.metrics,10m)=1` warns when
the public desktop host stops exposing metrics.

Follows the existing `flowercore-print-ollama.yaml` pattern — import
manually into Zabbix and link to the Print/Desktop host. Not a K8s
manifest; ArgoCD ignores.

Grafana dashboard JSON is drafted at
`apps/monitoring/flowercore-remotedesktop-grafana-dashboard.json`
but still needs a ConfigMap wrap + Grafana Deployment volume mount
in noc-monitoring.yaml before it ships (follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:30:32 -05:00
Andrew Stoltz
6c1375b21a ttsreader: bump image to v202604232310 for Media Session API + Bible lexicon
Pulls in FlowerCore.TtsReader@63e6b62: P1.1 Media Session API wiring in
fc-media-session.js + quick-player.js + rendered-chapter-player.js, and
P1.2 biblical-name pronunciation lexicon auto-seed on Bible-source
project creation plus apply-bible-defaults endpoint + MCP tool for
existing projects. Tests 81 -> 97 all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:15:53 -05:00
Andrew Stoltz
82529ed9b5 fix(guacamole): detect managed embeds from client URL 2026-04-23 21:02:05 -05:00
Andrew Stoltz
3ea8a56dab fix(guacamole): disable logout for managed embeds 2026-04-23 20:51:15 -05:00
Andrew Stoltz
9272abc225 Use absolute cluster DNS for ttsreader piper 2026-04-23 20:49:40 -05:00
Andrew Stoltz
436185818d fc-distribution: restrict public IngressRoute to GET+HEAD only
Live verification 2026-04-24 caught POST /blobs on dist.flowercore.io
returning 201 Created with the blob persisted — admin write operations
reachable on the public surface. Controller-level strict entitlement
was on, but that gates reads; writes weren't blocked at all.

Fix: add Method(GET) || Method(HEAD) to the Host match on the public
IngressRoute. POST/PUT/PATCH/DELETE now miss every route for
dist.flowercore.io and Traefik returns 404 before the pod sees the
request. Edge-level defense-in-depth on top of the controller's
strict-mode entitlement check.

The internal IngressRoute for dist.iamworkin.lan stays unrestricted —
admin POST /blobs + POST /manifests flows keep working from the lab.
2026-04-23 20:12:25 -05:00
Andrew Stoltz
c3cc404beb fc-distribution: add dist.flowercore.io public surface (Cloudflare A record + Origin Cert + profile-header middleware)
Lights up dist.flowercore.io end-to-end:
- cf-origin-flowercore-io Secret (literal *.flowercore.io Origin Cert,
  copied from the telephony/gitea-public/matrix/mail/flowercore/fc-landing
  pattern — not via OnePasswordItem yet).
- Traefik Middleware dist-public-profile-header: strips any caller-supplied
  X-FC-Distribution-Profile, injects 'public' so the controller's
  NamedEntitlementResolverRouter routes to the strict resolver.
- IngressRoute fc-distribution-public: Host(`dist.flowercore.io`) ->
  same backing Service as the internal dist.iamworkin.lan route.
  Middleware attached; cert secret cf-origin-flowercore-io.

Cloudflare DNS A record dist.flowercore.io -> 74.40.140.24 (proxied)
already created 2026-04-24 via Cloudflare API (record id
e9b957511556f37ff6763f4441acbc45).

Controller entitlement config is still DefaultAllow=false + empty
PublicEditions on the 'public' profile, so every public request
returns 403 by default. Populate FlowerCore__Distribution__EntitlementPublic__PublicEditions__0
via env var when ready to expose specific editions.
2026-04-23 20:10:29 -05:00
Andrew Stoltz
90627819cc fc-distribution: bump to v202604240010 (Phase 4 header-routing controller) 2026-04-23 19:23:35 -05:00
Andrew Stoltz
c97d486a3d feat(fc-segmentdisplay): switch tls certificate to dns01 2026-04-23 18:39:17 -05:00
Andrew Stoltz
209bdc16cd fc-distribution: bump to v202604232310 (Front D entitlement wired into ManifestsController) 2026-04-23 18:11:21 -05:00
Andrew Stoltz
3999634b06 Seed ttsreader piper voices before startup 2026-04-23 17:18:57 -05:00
Andrew Stoltz
61538d3712 fc-distribution: bump to v202604232212 (disable 30MB body size limit on POST /blobs) 2026-04-23 17:11:56 -05:00
Andrew Stoltz
ccaac367af fc-distribution: bump to v202604232206 (adds POST /blobs endpoint) 2026-04-23 17:07:13 -05:00
Andrew Stoltz
407d473b71 feat(infra): route dns preflight through flowercore dns 2026-04-23 17:03:22 -05:00
Andrew Stoltz
f9593e494a Allow ttsreader piper voice downloads 2026-04-23 16:50:21 -05:00
Andrew Stoltz
5b6c7b97fc feat(fc-distribution): bump image to v202604232145 — cert chain endpoint
- Serves GET /manifests/{edition}/{version}.cert (leaf+intermediate PEM)
- Adds CertChainPem migration on startup (nullable column)
- ManifestSignService now embeds version-specific certChainUrl

Provisioning Agent's verify step will flip from ChainNotServed (Phase 2A
soft-pass) to Valid once a fresh edition is published with this image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:43:56 -05:00
Andrew Stoltz
a76eeb5c39 Add dedicated selectable piper for ttsreader 2026-04-23 16:37:03 -05:00
Andrew Stoltz
8a960ffc73 feat(fc-distribution): K8s manifest for Phase 1 edition publisher
Adds apps/fc-distribution/{fc-distribution.yaml,kustomization.yaml,README.md}.
Ships the FlowerCore.Distribution service (Blazor + REST + MCP) backed by
Synology NFS for SQLite catalog + content-addressed blob root.

Contents:
- Namespace fc-distribution
- 3x OnePasswordItem (FlowerCore Code Signing CA informational + per-edition
  signing keys for kiosk-standard and aistation-field)
- Deployment: localhost/fc-distribution:v202604232000 (already imported to
  rke2-server via ctr), pinned to rke2-server nodeSelector because Synology
  NFS ACL restricts writes to that node, emptyDir for /tmp + /app/logs,
  inline NFS for /data (subPath distribution/data) and /blobs (subPath
  distribution/blobs), Secret volume mounts for /signing/<edition>.
  readOnlyRootFilesystem + runAsUser 1654 + drop ALL capabilities.
  Probes: startup + readiness on /healthz, liveness on tcpSocket (defense
  against future auth middleware accidentally gating /healthz).
- Service (ClusterIP :80 -> container :8080)
- Certificate (cert-manager ClusterIssuer step-ca-acme, dist.iamworkin.lan,
  90d / 30d renew). pfSense Unbound override dist.iamworkin.lan ->
  10.0.56.200 already in place (req'd for HTTP-01).
- IngressRoute (Traefik websecure, Host rule on dist.iamworkin.lan)

Env var keys align with the scaffold:
  FlowerCore__Database__ConnectionStrings__Sqlite
  FlowerCore__Distribution__Blobs__Root
  FlowerCore__Distribution__Signing__EditionCerts__<slug>__{CertPath,KeyPath}

Consumer: ProvisioningAgent (USB-side, Phase 2) — see
FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:59:50 -05:00
Andrew Stoltz
686dbacc66 Bump TTS Reader image for render follow-along 2026-04-23 15:54:07 -05:00
Andrew Stoltz
5ccf055465 check-pfsense-dns: add live-cluster scan
Extends the pre-merge DNS gate to (optionally) scan live-cluster
Certificates + IngressRoutes via kubectl. Closes the coverage hole
where a service's IngressRoute gets deployed from its own repo (not
from bluejay-infra/apps/) and the manifests-only scan misses it —
fc-retail/retail-web-tls stuck Issuing for 15h on a missing pfSense
Unbound override was exactly this class of bug.

Auto mode: if kubectl is on PATH and usable, live-scan runs silently.
--live  forces it (and errors out if kubectl can't reach the cluster).
--no-live skips live entirely (CI path with no cluster access).

Immediate live-scan finding on 2026-04-23: 10 orphan *.iamworkin.lan
IngressRoutes from failed e2e / codex / smoke / deleteproof test runs
in fc-php + fc-tenant-default (2026-04-16/17). None have DNS overrides
so their Certificates have been failing to issue for 7 days — the new
CertManagerCertificateNotReady alert will catch them too. Cleanup
(delete abandoned IngressRoutes + Certificates + CertificateRequests)
is a separate task; this check now surfaces them.
2026-04-23 15:51:19 -05:00
Andrew Stoltz
4da60820c6 Deploy TTS Reader queue presentation fix 2026-04-23 15:13:21 -05:00
Andrew Stoltz
1cc4324cfb Deploy TTS Reader import and preview fixes 2026-04-23 14:28:08 -05:00
Andrew Stoltz
bfc755057e fix(agent-zero): use streamable http for chat mcp 2026-04-23 13:54:06 -05:00
Andrew Stoltz
d6008ee205 fix(agent-zero): allow chat mcp pod port 2026-04-23 13:29:36 -05:00
Andrew Stoltz
39fe6f1dba fix(agent-zero): route chat mcp in-cluster 2026-04-23 13:26:10 -05:00
Andrew Stoltz
90fcf0cd5d fix(agent-zero): expose openai provider key 2026-04-23 13:21:12 -05:00
Andrew Stoltz
ffef5c9126 Deploy TTS Reader annotation timeout fix 2026-04-23 13:06:17 -05:00
Andrew Stoltz
634e90a9ee Deploy TTS Reader quick hardening release 2026-04-23 12:47:45 -05:00
Andrew Stoltz
86ccca18e3 Add Chat MCP server to Agent Zero 2026-04-23 12:41:58 -05:00
Andrew Stoltz
1c5caf3f40 Deploy TTS Reader v20260423114016 2026-04-23 11:57:39 -05:00
Andrew Stoltz
d3db19b0ca guacamole: enable json auth for remotedesktop sso 2026-04-23 11:27:30 -05:00
Andrew Stoltz
702a6e4f52 fix(agent-zero): use short DNS name to avoid CoreDNS template hijack
The full FQDN fc-llm-bridge.fc-llm-bridge.svc.cluster.local has 4 dots,
which is less than the pod's ndots:5 threshold. The resolver then
applies every entry in the search list BEFORE falling through to the
bare FQDN, and the CoreDNS 'template iamworkin.lan' catch-all matches
"...svc.cluster.local.iamworkin.lan" and returns Traefik VIP
10.0.56.200. The egress NetworkPolicy blocks that VIP (0.0.0.0/0
EXCEPT 10.0.0.0/8), so curl hangs for 30-134s and returns HTTP 000.

Reference: feedback_coredns_ndots_template_collision memory.

Fix: use "fc-llm-bridge.fc-llm-bridge.svc" (2 dots, still <5 so search
expansion still fires, but the first suffix "...svc.cluster.local"
hits the Kubernetes plugin in CoreDNS and returns the real ClusterIP
10.43.67.125 before the iamworkin.lan template is ever consulted).

Verified: pod-exec curl fc:cheap → HTTP 200 with a real chat.completion
envelope (Ollama/gemma3:4b via bridge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:02:09 -05:00
Andrew Stoltz
6cbb5d8792 fix(agent-zero): NetworkPolicy egress rule for fc-llm-bridge (ADR-088)
The chat_model flip (62db15c) pointed Agent Zero at
fc-llm-bridge.fc-llm-bridge.svc.cluster.local:8080 but the existing
agent-zero-netpol only allowed egress to specific node IPs
(10.0.56.20:11434, 10.0.57.17:11434, 10.0.57.16:5200, 10.0.56.11:6443)
plus public-internet (with RFC1918 exclusion). ClusterIP traffic to
10.43.0.0/16 was implicitly denied, so pod-exec curl to the bridge
timed out after 134s.

Adds an egress rule allowing TCP 8080 to the fc-llm-bridge namespace
(matched by kubernetes.io/metadata.name which K8s 1.22+ sets
automatically). No ingress changes needed — fc-llm-bridge has no
NetworkPolicy, so the ingress side is already open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:59:17 -05:00
Andrew Stoltz
62db15c69c feat(agent-zero): route chat_model through fc-llm-bridge (ADR-088)
Flips Agent Zero's chat_model from direct local Ollama (gemma3:12b via
the 127.0.0.1:11434 sidecar proxy) to the FlowerCore LLM Bridge
(fc:balanced tier, OpenAI-compatible, Anthropic Claude Sonnet under the
hood) so chat turns are spend-tracked and can dispatch to any provider
via a single tier alias.

Scope is intentionally minimal and reversible:
  - chat_model: ollama/gemma3:12b/127.0.0.1:11434
              → openai/fc:balanced/fc-llm-bridge internal service URL
  - utility_model, embedding_model, browser_model: UNCHANGED
    (stay on local 127.0.0.1 Ollama sidecar — no spend, low latency,
    not worth routing through the bridge for small-model traffic).

Auth: new A0_SET_chat_model_api_key env var wired to the
fc-llm-bridge-api-keys Secret (field: agent-zero-k8s). The Secret is
synced by a new OnePasswordItem pointing at "FC LLM Bridge API Keys"
in the IAmWorkin vault. Bearer-token auth is now accepted by the
bridge (FlowerCore.LlmBridge@3225f1f).

Rollback: revert this commit; old image v202604231449 is still present
on all RKE2 nodes, and Agent Zero's strategy: Recreate makes the flip
atomic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:54:27 -05:00
Andrew Stoltz
84634f59f0 chore(fc-llm-bridge): bump image to v202604231520
Ships the Bearer-token auth fix (FlowerCore.LlmBridge@3225f1f) so Agent
Zero's OpenAI provider can authenticate with Authorization: Bearer in
addition to the original X-Api-Key header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:51:57 -05:00
Andrew Stoltz
4cd5806fd0 fix(fc-llm-bridge): set dnsConfig ndots=2 to prevent CoreDNS wildcard hijack
Pods in this cluster inherit ndots=5. External FQDNs with <5 dots (like
api.anthropic.com) are expanded through the search path first, and the 4th
suffix `api.anthropic.com.iamworkin.lan` matches CoreDNS' `template IN A
iamworkin.lan` wildcard — resolves to Traefik VIP 10.0.56.200. TLS connect
lands on Traefik's default cert and the AnthropicClient rejects with
RemoteCertificateNameMismatch/RemoteCertificateChainErrors.

Setting ndots=2 makes the resolver try the bare FQDN first (3 dots in
api.anthropic.com), so the search path never fires.

Reference: memory feedback_coredns_ndots_template_collision. Wider follow-up:
the CoreDNS template plugin should add fallthrough for external public suffixes,
so every FC service calling external HTTPS APIs stops hitting this trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:42:17 -05:00
Andrew Stoltz
11c48bef30 chore(fc-llm-bridge): bump to v202604231449 (Budget 1.0.1 multi-provider dispatcher)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:36:05 -05:00
Andrew Stoltz
a86e87050b fix(fc-llm-bridge): anthropic secret key is 'password' not 'credential'
The 1Password item "Claude API Key" stores the key in a standard Password
field (labeled `password`), so the OnePasswordItem operator creates the K8s
Secret with key `password`. Deployment was referencing `credential`, which
made the pod fail with CreateContainerConfigError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:29:32 -05:00
Andrew Stoltz
0214f94ac4 chore(fc-llm-bridge): bump image to v202604231424 (first live tag)
Built from FlowerCore.LlmBridge@6d285b5 (initial scaffold). Imported on all
three RKE2 nodes via podman save + ctr import. Replaces v00000000000000
placeholder — ArgoCD sync will roll the pod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:28:05 -05:00
Andrew Stoltz
a1b8eb379d feat(fc-llm-bridge): stage ADR-088 manifests (not yet applied)
Staged but NOT applied. Do not git push until the two pre-requisites below
are done. See apps/fc-llm-bridge/README.md for the full order-of-ops.

Manifests (apps/fc-llm-bridge/fc-llm-bridge.yaml, 8 docs):
  - Namespace fc-llm-bridge
  - OnePasswordItem anthropic-api-key (existing Claude API Key item)
  - OnePasswordItem fc-llm-bridge-api-keys (NEW item, pending creation)
  - PersistentVolumeClaim fc-llm-bridge-data (2Gi longhorn)
  - Deployment fc-llm-bridge (port 8080, uid 1654, readOnlyRootFilesystem,
    tcpSocket probes to survive future ApiKeyAuthMiddleware reordering)
  - Service fc-llm-bridge ClusterIP
  - Certificate fc-llm-bridge-cert (step-ca-acme)
  - IngressRoute fc-llm-bridge (fc-llm-bridge.iamworkin.lan, websecure)

Pre-requisites BEFORE git push:
  1. pfSense Unbound override fc-llm-bridge.iamworkin.lan -> 10.0.56.200
     (currently NXDOMAIN -- verified via nslookup and check-pfsense-dns.py).
     Skipping this step puts cert-manager HTTP-01 into ~2h backoff.
  2. Create 1Password item `FC LLM Bridge API Keys` in vault IAmWorkin with
     password fields: agent-zero-ws, agent-zero-k8s, spare-1, spare-2.
  3. Build + import localhost/fc-llm-bridge:v<tag> to rke2-server +
     rke2-agent1 + rke2-agent2. Bump image tag from placeholder
     v00000000000000 before committing the apply.

Related: ADR-088 (FlowerCore.Notes/ARCHITECTURE.md), design doc at
FlowerCore.Notes/docs/ai-agents/agent-zero-anthropic-bridge.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 03:10:36 -05:00
Andrew Stoltz
9a1665907c fc-signalcontrol: align live port and selectors 2026-04-22 23:22:14 -05:00
Andrew Stoltz
899804215a statefulsets: align guacamole and matrix drift defaults 2026-04-22 23:11:47 -05:00
Andrew Stoltz
1dc66738e6 zabbix: align postgres tracking label 2026-04-22 22:50:24 -05:00
Andrew Stoltz
5623a272c5 zabbix: include statefulset defaults 2026-04-22 22:39:31 -05:00
Andrew Stoltz
3d3f91160b monitoring: add Print.Web Ollama Zabbix template 2026-04-22 22:07:40 -05:00
Andrew Stoltz
93f77c1844 fix(monitoring): use bluejay_v2 auth for snmp-nas (not public_v2)
Synology NAS is configured with community bluejay_monitor
(→ snmp.yml auth 'bluejay_v2'), not public. public_v2 was returning
HTTP 500 from snmp-exporter for this target. Verified bluejay_v2
returns metrics.

Keeps printer (10.0.58.107) on public_v2 — Epson ET-3750 uses
community "public" as documented in its SNMP settings.
2026-04-22 21:32:14 -05:00
Andrew Stoltz
59efc460fd fix(irc): use short name for unrealircd in anope + thelounge configs
Same CoreDNS iamworkin.lan template + ndots:5 hijack as the irc-notify fix.
Anope services (nickserv/chanserv/memo) have been disconnected from unrealircd
for weeks ("Host is unreachable" every 3s). Thelounge server defaults pointed
at the same broken FQDN.

Short name unrealircd.irc.svc resolves to the ClusterIP directly.
2026-04-22 21:23:38 -05:00
Andrew Stoltz
02959f1ac6 docs: deployment runbook + pfSense DNS pre-merge check
Adds a real README describing the 4-step deploy flow, with pfSense Unbound
host overrides as step 1 (the prerequisite that, if skipped, silently breaks
cert-manager HTTP-01 for ~2h per cert until manually diagnosed — root cause
of the 2026-04-22 cluster-wide cert outage).

Adds scripts/check-pfsense-dns.py: parses every apps/*/*.yaml, extracts
hostnames from Certificate.spec.dnsNames and Traefik IngressRoute
`Host(...)` match rules, and fails the check if any don't resolve via the
system DNS (pfSense Unbound on this LAN). Ignores IRC server-link labels,
image tags, comments — only checks hostnames cert-manager and Traefik will
actually use.

Run before `git push` or wire into pre-commit / Gitea Actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:11:24 -05:00
Andrew Stoltz
a3aa84bdae fc-ttsreader: bump image to v20260422201135 (Quick Read highlight no-reflow fix)
Quick Read's active-sentence highlight was changing font-weight from
regular to semibold, which shifted glyph widths and reflowed the whole
paragraph mid-playback. New image drops the weight change and uses a
1px box-shadow ring instead for a stable layout.

Built from FlowerCore.TtsReader@e77d69d.
2026-04-22 20:20:30 -05:00
Andrew Stoltz
01cb9a557f fc-ttsreader: deploy fixed reader image 2026-04-22 16:13:15 -05:00
Andrew Stoltz
0fa46ad53b fc-ttsreader: deploy reader UI split image 2026-04-22 15:57:58 -05:00
Andrew Stoltz
1ded5a61c0 fc-segmentdisplay: add TLS ingress gitops stub 2026-04-22 15:55:54 -05:00
Andrew Stoltz
3c1d212251 fc-messageboard: deploy latest web image via gitops 2026-04-22 15:48:05 -05:00
Andrew Stoltz
c0547a9964 fc-signalcontrol: switch probes to tcpSocket — middleware blocks /health
The app's ApiKeyAuthenticationMiddleware runs BEFORE /health is mapped, so
unauthenticated probe requests get 404. tcpSocket probes verify the listener
is up without auth, which is correct for an internal K8s probe (kubelet
talks pod IP directly, not externally).

Real fix is in the app: move /health before the middleware or mark it
[AllowAnonymous]. Tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:21:04 -05:00
Andrew Stoltz
973c1dae72 fc-signalcontrol: fix probe path /metrics/prometheus -> /health
The app exposes /health (Program.cs:91 maps a Healthy text response) but does
NOT expose /metrics/prometheus. K8s liveness/readiness probes against a 404
endpoint kept the pod in CrashLoopBackOff after PVC mount was added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:15:07 -05:00
Andrew Stoltz
475737b36f fc-signalcontrol: add PVC + volumeMount for SQLite data dir
Live cluster had a Longhorn PVC `signalcontrol-data` mounted at /app/data
since 2026-04-14, but the bluejay-infra git manifest never declared it. As a
result, when ArgoCD recreated the Deployment from git (after deletion to fix
an unrelated selector-label mismatch caught during cert-manager recovery),
the new pod started without /app/data and crashed with `SQLite Error 14:
unable to open database file 'data/signalcontrol.db'`.

Bring git in line with reality: declare the PVC, mount it, and switch the
Deployment to `strategy.type: Recreate` (RWO PVC blocks rolling updates per
existing memory feedback_k8s_rwo_rollout.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:10:10 -05:00
Andrew Stoltz
3bb3801fbd fix(monitoring): use short service name for irc-notify IRC_HOST
CoreDNS iamworkin.lan template + ndots:5 was hijacking
unrealircd.irc.svc.cluster.local lookups → Traefik VIP → timeout.
Every alert since ~2026-04-09 silently failed with "IRC send failed: timed out",
which also killed the thermal-printer path (routed through irc-notify).

Same fix pattern as guacamole@28b7600.
2026-04-22 09:55:23 -05:00
Andrew Stoltz
28b76001a8 fix(guacamole): use short service name for GUAC_URL (CoreDNS template collision)
The guac-k8s-sync CronJob has been crash-looping (exit 7) since the
2026-04-11 run. Root cause: CoreDNS has an `*.iamworkin.lan`
template wildcard, and the Kubernetes pod resolv.conf ships with
`ndots:5` plus a search list that includes `iamworkin.lan`.

Resolving `guacamole.guacamole.svc.cluster.local` (4 dots < 5) goes
through search-suffix expansion BEFORE the bare FQDN. The iamworkin.lan
suffix makes it `guacamole.guacamole.svc.cluster.local.iamworkin.lan`,
which matches the template and answers with Traefik LB VIP
10.0.56.200. That VIP has no pod-network hairpin route, so curl exits
with 'No route to host'.

Using the short name `http://guacamole:8080` keeps the query at 0
dots, search expansion runs on the bare name, and the in-namespace
`guacamole.svc.cluster.local` suffix hits the Kubernetes CoreDNS
plugin directly (ClusterIP 10.43.229.31).

Alt fixes considered but not taken: trim the CoreDNS template regex
to exclude `.svc.cluster.local.` prefixes (cross-cutting, higher
blast radius); trailing-dot FQDN in the URL (curl/Java HTTP clients
handle inconsistently).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:52:53 -05:00
Andrew Stoltz
0c67fa5356 asterisk: add *832 test-entry dialplan for VDAY workflow AATs
Lets live SIP AATs (ext 901–904, from-internal context) dial *832 to
exercise the Victory Day workflow + Fun Menu + AsteriskGameHandler path
without routing through Twilio. Mnemonic: *832 = V-D-A (8-3-2) from the
V-D-A-Y keypad pattern.

Maps to Stasis(flowercore-pbx,inbound-pstn,+15074618329) — same call-
type classification as a real Twilio-inbound call to the VDAY DID, so
InboundPstnHandler routes to the seeded VDAY workflow identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:51:49 -05:00
Andrew Stoltz
62e342cfb2 guacamole: consolidate nodeSelector — use rke2-server for guacd too
Previous commit 90deacd raced with the user's f0733ff (which had
already pinned the guacamole web Deployment to rke2-server for the
NFS ACL). That left two nodeSelector blocks on the web pod and an
inconsistent agent2 pin on guacd. Align both pods to rke2-server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:36:25 -05:00
Andrew Stoltz
90deacd154 guacamole: pin guacd + web to rke2-agent2 for NFS recordings mount
Synology NFS export at /volume1/kubernetes currently grants mount
permission only to 10.0.56.13 (rke2-agent2). rke2-agent1 gets
"access denied by server". guacd + guacamole web both need the
recordings volume, so co-locating is also efficient. Remove the
nodeSelector once the Synology NFS ACL opens to all cluster nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:35:13 -05:00
Andrew Stoltz
f0733ff89d feat(guacamole): wire 1Password vault extension + logback into deployment
Adds the 1Password vault JAR to the Guacamole pod so connection params
like ${OP:ItemTitle/fieldLabel} are resolved from 1Password Connect at
tunnel-open time. Credentials never land in MySQL — only token literals.

Deployment changes:
- env: OP_CONNECT_URL=http://10.0.56.10:8180, OP_VAULT_ID=..., plus
  OP_CONNECT_TOKEN from secret/guacamole-1password-token/credential.
- env: ENABLE_ENVIRONMENT_PROPERTIES=true so OP_* env vars render as
  op-connect-url / op-connect-token / op-vault-id properties the
  extension reads.
- volumeMount for guacamole-vault-jar at
  /etc/guacamole/extensions/guacamole-vault-1password-1.0.0.jar
- volumeMount for guacamole-logback so we see DEBUG token-inject lines.
- nodeSelector kubernetes.io/hostname=rke2-server — the Synology NFS
  export for /volume1/kubernetes currently only allows rke2-server.
  Followup: add rke2-agent1/2 to the export and remove this selector.

New ConfigMaps:
- guacamole-vault-jar (binaryData, ~312KB JAR, Gson shaded, built from
  FlowerCore.Notes/k8s/guacamole/extensions/1password-vault via mvn).
- guacamole-logback with DEBUG on io.flowercore.guacamole.vault — drop
  to INFO once resolution is proven stable.

Existing guacamole-properties: added onepassword-vault to extension-priority.

The guacamole-1password-token Secret is NOT in git — it holds a verbatim
copy of the onepassword-connect-operator bearer token. Followup task:
provision a scoped Connect token for Guacamole and rotate the copy out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:32:51 -05:00
Andrew Stoltz
313bdcb21a guacamole: NFS subPath — Synology exports /volume1/kubernetes root only
First pass used nfs.path=/volume1/kubernetes/guacamole/recordings,
which triggered "mount.nfs: access denied by server" on rke2-agent1.
Synology NFS export is scoped to /volume1/kubernetes; match the
working fc-desktop pattern: mount the export root and select the
subdirectory via volumeMount.subPath.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:23:49 -05:00
Andrew Stoltz
5f4818bd96 guacamole: wire session recording to Synology NFS
Phase 5 of docs/infrastructure/guacamole-customization-plan.md:

- Mount /volume1/kubernetes/guacamole/recordings (Synology 10.0.58.3)
  into both guacd (writer) and guacamole web (reader) at
  /var/lib/guacamole/recordings
- Set RECORDING_SEARCH_PATH env on guacamole web -- the Guacamole
  Docker entrypoint treats any RECORDING_* var as an enable signal
  for the history-recording-storage extension (symlinks the JAR
  from /opt/guacamole/environment/RECORDING_/extensions/ into
  GUACAMOLE_HOME/extensions/)

Per-connection recording still requires setting recording-path on
each connection in MySQL -- follow-up task. This commit enables
the plumbing; no sessions record yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:15:55 -05:00
Andrew Stoltz
fff998dab5 matrix, zabbix: add volumeMode to postgres PVC templates
Same ArgoCD + SSA self-heal loop pattern as guacamole (20e4130):
K8s defaults volumeMode=Filesystem on volumeClaimTemplates at
creation, git omits it, argocd-controller owns the atomic list so
every reconcile sees drift, and volumeClaimTemplates is immutable
so it can never reconcile. Adding the field closes both loops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:48:43 -05:00
Andrew Stoltz
20e4130c74 guacamole: add volumeMode to guac-mysql PVC template
Closes the infra-guacamole OutOfSync sync loop. K8s API sets
volumeMode=Filesystem as a default on volumeClaimTemplates at creation,
but the git manifest omitted it. ArgoCD uses ServerSideApply with
atomic ownership of volumeClaimTemplates, so every sync saw a
desired/live mismatch on that one field. volumeClaimTemplates is
immutable after creation so ArgoCD could never reconcile it --
autoHealAttemptsCount climbed to 6091. Adding the field to git
matches live and breaks the loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:29:40 -05:00
Andrew Stoltz
3cf675b8c3 ttsreader: wire operator secrets through 1password 2026-04-17 10:05:24 -05:00
Andrew Stoltz
2a9f2e4540 Improve TTS Reader workspace card layout 2026-04-17 03:57:23 -05:00
Andrew Stoltz
b15a35a258 Fix TTS Reader character layout 2026-04-17 03:48:03 -05:00
Andrew Stoltz
3f4985ee13 Deploy TTS Reader queue feedback fix 2026-04-17 03:34:28 -05:00
Andrew Stoltz
e535a8d34b Deploy TTS Reader voice preview update 2026-04-17 02:13:09 -05:00
Andrew Stoltz
6ddbd2cae5 Point TTS Reader at Pi Ollama defaults 2026-04-17 00:53:45 -05:00
Andrew Stoltz
e9608651f7 Bump TTS Reader image to v20260417001119 2026-04-17 00:33:29 -05:00
Andrew Stoltz
abdb7a806e Bump TTS Reader image to v20260416234817 2026-04-16 23:53:42 -05:00
Andrew Stoltz
7afb5043c4 Fix ttsreader forwarded header handling 2026-04-16 21:55:46 -05:00
Andrew Stoltz
1883953cb8 Enable live Piper and ffmpeg for fc-ttsreader 2026-04-16 21:18:43 -05:00
Andrew Stoltz
9c555db083 telephony: bump web image to v202604170153 2026-04-16 20:56:30 -05:00
Andrew Stoltz
cb349c6764 Configure TtsReader Bible corpus path 2026-04-16 20:44:23 -05:00
Andrew Stoltz
3888c4c3e0 Align fc-ttsreader with hardened runtime 2026-04-16 20:06:53 -05:00
Andrew Stoltz
7aec403e96 Pin telephony-web v202604170059 2026-04-16 20:03:01 -05:00
Andrew Stoltz
5685ab0550 Improve The Lounge MOTD contrast 2026-04-16 19:49:53 -05:00
Andrew Stoltz
d4d3455ef2 Style The Lounge as FlowerCore IRC 2026-04-16 19:45:52 -05:00
Andrew Stoltz
29d557003f fix: deploy responsive telephony debug menu 2026-04-16 19:45:49 -05:00
Andrew Stoltz
719aa8c1c6 fix: align desk phone dtmf mode with yealink provisioning 2026-04-16 19:36:37 -05:00
Andrew Stoltz
63cf5193ef Use recreate strategy for UnrealIRCd 2026-04-16 19:33:28 -05:00
Andrew Stoltz
ef0e1f2505 fix: update telephony web image tag 2026-04-16 19:30:36 -05:00
Andrew Stoltz
f8eb946704 Add IRC MOTD file 2026-04-16 19:29:43 -05:00
Andrew Stoltz
929449c55c apps: fc-chat refactor + fc-menuboard app split
- fc-chat.yaml: TLS/IngressRoute only (Deployment managed by deploy script, matches fc-signage/fc-mysql/fc-kiosk pattern)
- fc-menuboard: new app bundle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 19:25:25 -05:00
Andrew Stoltz
9d0da584af Add The Lounge web IRC client 2026-04-16 19:10:10 -05:00
Andrew Stoltz
4f33d7a053 fix(telephony): chown /shared-tts in initContainer + harden security context
Two follow-ups to the Piper TTS wire-up landed in d3ffad9:

1. Telephony-web runs as uid 1654 (non-root), but the hostPath at
   /tmp/tts-audio is owned by root:root 0755. Pod couldn't write .sln16
   files — every Piper call would succeed at the HTTP layer and then
   fall back to the sound map when File.WriteAllBytesAsync threw
   "Permission denied." Extend the existing fix-data-perms initContainer
   to chown the shared-tts mount too (0755 world-readable, so the
   Asterisk pod — running as a different uid — can still read).

2. Pod security context now explicitly sets runAsNonRoot: true + runAsUser
   1654 + runAsGroup 1654 (cluster policy), matching the pattern used
   by every other FlowerCore service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 16:29:21 -05:00
Andrew Stoltz
d3ffad9190 fix(telephony): PiperUrl 10.0.57.15 → .17 + shared-tts hostPath for TTS playback
Piper was never reachable on 10.0.57.15 — edge1's actual address is
10.0.57.17 (SSH config, project_edge1_sdcard memory). Every telephony
prompt hit the 8s HttpClient timeout and fell back to the built-in sound
map (vm-advopts, vm-goodbye, beep) instead of speaking the real workflow
text. Verified from noc1: `curl http://10.0.57.17:8500/health` returns
HTTP 200 in 6ms, `POST /tts` returns a 16kHz mono WAV in 606ms.

Changes:

- apps/telephony/telephony.yaml
  - `Tts.PiperUrl` → `http://10.0.57.17:8500`
  - NetworkPolicy egress allow → `10.0.57.17/32:8500`
  - Header comment now documents the POST /tts {"text":"..."} contract
  - telephony-web pod mounts `/shared-tts` from hostPath `/tmp/tts-audio`
    (rke2-agent1). This is where `AsteriskProvider.SpeakTextAsync` writes
    the synthesized .sln16 before calling ARI `Play sound:tts/<name>`.

- apps/asterisk/deployment.yaml
  - Asterisk pod mounts the same hostPath at
    `/var/lib/asterisk/sounds/tts` so it can read and play what
    telephony-web wrote. Both deployments have
    `nodeSelector: kubernetes.io/hostname: rke2-agent1` so the hostPath
    is guaranteed to be the same directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 16:19:48 -05:00
Andrew Stoltz
403d061664 fix(asterisk): hostAlias downloads.asterisk.org so sounds actually download
CoreDNS wildcard for iamworkin.lan catches unresolved names and returns
the Traefik VIP (10.0.56.200), so downloads.asterisk.org from inside a
pod returns 404 from Traefik rather than the real Sangoma mirror. Pin
the real IP (165.22.184.19 = oss-downloads.sangoma.com) via hostAliases
so curl reaches the actual server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:54:39 -05:00
Andrew Stoltz
45a2cb3f93 fix(asterisk): curl -k for sounds download — cluster TLS MITM
Cluster egress goes through a step-ca-fronted TLS proxy that install-sounds
doesn't trust ("SSL certificate problem: self-signed certificate"). The
Asterisk core sounds tarball is a public artifact; integrity is enforced
downstream when Asterisk plays the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:48:34 -05:00
Andrew Stoltz
e1922564ae fix(asterisk): actually install core sounds (en, ulaw 1.6.1)
The install-sounds init container was a stub that left /var/lib/asterisk/sounds/en
empty. Result: every SpeakText fallback path (vm-advopts, vm-goodbye, characters:*,
digits/*, beep, pbx-invalid) resolved to a missing file, Asterisk silently failed
each Playback, zero RTP was produced, and callers heard dead air. This is why
dialing *0 (Settings Menu) or *100 (Debug IVR) "picks up quietly" — there is
literally nothing to stream.

Replaced the stub with alpine:3.20 + curl + tar that downloads the pinned
asterisk-core-sounds-en-ulaw-1.6.1.tar.gz (~10 MB) from downloads.asterisk.org
and unpacks it into the sounds emptyDir. Idempotent — skips download if
vm-goodbye is already present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:42:58 -05:00
Andrew Stoltz
7762a0079a Add K8s deployment manifests for SignalControl, MessageBoard, Chat, TTS Reader
Full deployment manifests (Namespace, Deployment, Service, Certificate,
IngressRoute) for 4 new FlowerCore services with port 8080, ClusterIP
on port 80, cert-manager step-ca-acme TLS, and /metrics/prometheus
health probes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:23:55 -05:00
Andrew Stoltz
ab7435a43a Update Agent Zero, Asterisk, and Telephony K8s manifests
- Update agent-zero deployment configuration
- Update Asterisk configmap and deployment
- Update telephony service manifest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:12:08 -05:00
Andrew Stoltz
53234bfcc8 Fix K8s sync script: use grep instead of python3
bitnami/kubectl image doesn't have python3. Replaced all python3
JSON parsing with grep/cut for auth token and connection data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:02:02 -05:00
Andrew Stoltz
cf572c167f Update Guacamole: branding JAR, K8s sync CronJob
- Updated bluejay-branding-1.0.0.jar with gold accents, hover fix,
  icon fix, pinstripe patterns, Blue Jay SVG logo
- Added guac-k8s-sync CronJob: runs every 2min, auto-updates pod
  names in Kubernetes exec connections when pods restart
- Fixed secret reference (guacamole-credentials, not guacamole-db-credentials)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:49:48 -05:00
Andrew Stoltz
7d5d0f86e7 Add signage service ingress manifests 2026-04-09 15:09:08 -05:00
Andrew Stoltz
8f59322329 Add step-ca TLS certs for mysql, php, desktop, signage, fc-landing
RKE2 Traefik has no ACME certResolver configured, so IngressRoutes
using certResolver: step-ca silently fall back to the Traefik default
self-signed cert. Fix by using cert-manager Certificate resources with
the step-ca-acme ClusterIssuer and tls.secretName in IngressRoutes.

- fc-landing: Add Certificate, change tls: {} to tls.secretName
- fc-mysql: New app (Certificate + IngressRoute only)
- fc-php: New app (Certificate + IngressRoute only)
- fc-desktop: New app (Certificate + IngressRoute only)
- fc-signage: New app (Certificate + IngressRoute, plus HTTP route for players)

Deployments/Services for mysql/php/desktop/signage are managed by
deploy scripts, not ArgoCD. These apps only manage TLS + ingress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:20:23 -05:00
Claude Code
8f8290e0da Increase ctx to 8192 (system prompt + 21 tools need >2048) 2026-04-08 20:07:27 +00:00
Claude Code
607192aaec Reduce ctx to 2048 for Pi 5 CPU speed 2026-04-08 19:40:52 +00:00
Claude Code
072d64a5e9 Fix model config: write config.json not config.yaml (plugin reads JSON) 2026-04-08 19:22:16 +00:00
Claude Code
acb19bee9c Write Ollama model config before initialize.sh (fix OpenRouter default) 2026-04-08 19:15:43 +00:00
Claude Code
e6fbe2d22b Mount extensions+theme directly in main container (symlinks lost by initialize.sh) 2026-04-08 18:12:07 +00:00
Claude Code
dbd6769537 Reference split tools ConfigMaps (tools-a/b/c) in init container 2026-04-08 18:09:55 +00:00
Claude Code
0af47f893a Split bluejay-tools into 3 ConfigMaps (K8s 262K annotation limit) 2026-04-08 18:09:49 +00:00
Claude Code
d16f72f089 Enable Blue Jay profile: init container, ConfigMap volumes, tools, extensions, theme 2026-04-08 18:07:13 +00:00
Claude Code
36e7369609 Add Blue Jay profile ConfigMaps (21 tools, prompts, extensions, theme) 2026-04-08 18:07:06 +00:00
Claude Code
3e5c017c4e Add agent-zero egress to monitoring NetworkPolicy, fix probe target to use K8s svc DNS 2026-04-08 17:36:23 +00:00
Claude Code
67e41febf5 Add agent-zero egress to monitoring NetworkPolicy for blackbox probes 2026-04-08 17:34:16 +00:00
Claude Code
c9f07108bd Fix edge1 Ollama IP (.15->.17), add monitoring ingress, add init container 2026-04-08 17:30:22 +00:00
Claude Code
f3919cf728 Add cert-manager Certificate for intranet ACME TLS auto-renewal 2026-04-05 08:47:42 -05:00
Claude Code
56442ecfbc Replace nginx+ConfigMap intranet with Blazor Server app
Replaces the 188KB ConfigMap-embedded HTML with a proper Blazor Server
deployment (fc-intranet-web:latest on port 5300). The old nginx deployment,
ConfigMaps (intranet-html, intranet-nginx-conf), and all embedded HTML are
removed. The intranet is now a .NET 10 Blazor app with live health monitoring,
REST API, 49 pages, and the unified Blue Jay theme.

Source: github.com/astoltz/FlowerCore.Intranet.Web
2026-04-04 19:29:28 -05:00
Andrew Stoltz
a07b6311b9 Add Blue Jay branding, kubectl-proxy, RBAC, and properties to Guacamole
- guacamole-branding ConfigMap with Blue Jay dark theme CSS
- guacamole-properties ConfigMap with ban/TOTP/session config
- kubectl-proxy sidecar on guacd for K8s pod exec connections
- guacd-exec ServiceAccount + ClusterRole/Binding for pod exec RBAC
- Volume mounts for branding JAR and properties on guacamole webapp

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:22:51 -05:00
Claude Code
331ae14d3f Update intranet: fcadmin links, Guacamole connections, 1Password deep-links 2026-04-03 13:40:09 -05:00
Claude Code
b291d0360b Update intranet HTML — deep cleanup 2026-03-28
- OpenVPN 9 servers, WiFi portal, Signage+RemoteDesktop on K8s
- Print.Web HTTPS via noc-proxy, 530 tests, 21 pages, 15 MCP
- Monitoring: 36 scrape jobs, 25 alert rules, 12 Grafana dashboards
- Remove BlueJay-Employee SSID (factory reset), fix WiFi to 4 SSIDs
- Fix Guacamole URL (guac -> guacamole), noc1 SSH typo, pfSense WAN igc3
- Add Signage, RemoteDesktop, WiFi Portal to DNS/service tables
- Update ArgoCD 22 apps, 41 namespaces, 49 IngressRoutes, Traefik v3.6.10
- IRC Anope marked CrashLoopBackOff, monitoring moved to K8s
- Total: 21,437+ tests across 13 services
2026-03-28 14:34:25 -05:00
Andrew M. Stoltz
090b29933f telephony-web v20260325d: global search, error pages, quick-create wizard 2026-03-25 17:58:56 -05:00
Andrew M. Stoltz
987b73c537 telephony-web v20260325c: workflow config validation, enhanced health checks, response compression, Serilog request logging 2026-03-25 17:47:27 -05:00
Andrew M. Stoltz
bf12474de9 telephony-web v20260325b: add SMS UnreadCount/LastMessagePreview columns to schema drift 2026-03-25 08:19:58 -05:00
Andrew M. Stoltz
f366dd5c90 telephony-web v20260325a: fix billing/RBAC 500s — replace IDbContextFactory with direct TelephonyDbContext injection 2026-03-25 08:11:59 -05:00
Andrew M. Stoltz
50146f8355 telephony-web v20260324n: rebuild-schema admin endpoint for production DB migration 2026-03-24 19:45:06 -05:00
Andrew M. Stoltz
ace06c5fb9 telephony-web v20260324m: model-driven schema drift — auto-creates ALL missing tables 2026-03-24 19:28:08 -05:00
Andrew M. Stoltz
7ed834f056 telephony-web v20260324l: schema drift fix for CustomRoles table 2026-03-24 19:03:26 -05:00
Andrew M. Stoltz
2b04c9e292 telephony-web v20260324k: RBAC policy editor, billing dashboard, 11081 tests ALL PASS 2026-03-24 18:55:03 -05:00
Andrew M. Stoltz
fafc2e510b telephony-web v20260324j: recording playback, SMS enhancements, notifications polish, dashboard shortcuts, all 11049 tests pass 2026-03-24 18:22:46 -05:00
Andrew M. Stoltz
fb1c622e62 telephony-web v20260324i: break-glass UI, 5 MCP tools, survey editor config, step palette 2026-03-24 17:37:19 -05:00
Andrew M. Stoltz
40cb7faef5 telephony-web v20260324h: setup wizard, REST smoke tests, survey route fix 2026-03-24 17:16:09 -05:00
Andrew M. Stoltz
bd79279b28 telephony-web v20260324g: schema drift fix (BridgeEvents, SurveyResponses tables), survey route fix 2026-03-24 16:53:21 -05:00
Andrew M. Stoltz
35b6b4f8e5 telephony-web v20260324f: remove Scalar/OpenApi packages (Swashbuckle conflict) 2026-03-24 16:06:11 -05:00
Andrew M. Stoltz
8d8b76c82b Fix telephony-web: revert Scalar (Swashbuckle conflict), use v20260324e 2026-03-24 16:02:32 -05:00
Andrew M. Stoltz
f3fde15002 Update telephony-web image to v20260324d, resolve merge conflicts 2026-03-24 15:55:52 -05:00
Andrew M. Stoltz
42d2894ed1 Update telephony-web image tag to v20260324d (Scalar API docs, webhook config, surveys, templates, member portal) 2026-03-24 15:55:40 -05:00
54 changed files with 29735 additions and 2986 deletions

109
README.md
View File

@@ -1,3 +1,110 @@
# bluejay-infra
Infrastructure manifests for ArgoCD
Infrastructure manifests for ArgoCD. An `ApplicationSet` in `argocd` namespace watches the `apps/*` directories in this repo and creates one `Application` per subdir (prefixed `infra-<name>`).
## Adding a new service to the cluster
Follow these steps in order. **Step 1 must run before step 3** — if you skip it, cert-manager HTTP-01 will silently fail for ~2h per cert (exponential backoff) until someone diagnoses the DNS.
### 1. Create or verify the FlowerCore.DNS A record (REQUIRED for current HTTP-01 manifests)
step-ca (the ACME CA on noc1) runs in a Podman container with host networking. Its container resolver uses pfSense Unbound (10.0.56.1), **not** cluster CoreDNS. So even though CoreDNS has a wildcard `*.iamworkin.lan → 10.0.56.200` for in-cluster lookups, step-ca cannot see it. Every new public hostname needs an explicit pfSense host override.
The management path is now `FlowerCore.DNS`, not `FlowerCore.Notes/scripts/pfsense-add-dns-overrides.py`. Add or verify the public A record there before you apply the manifest:
```bash
curl -sk https://dns.iamworkin.lan/api/v1/servers
# Find the pfSense serverId, then create the record using the host label only.
# Example: for foo.iamworkin.lan, use "name":"foo".
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
-H "Content-Type: application/json" \
-d '{"name":"<yourservice>","type":"A","data":"10.0.56.200","ttl":300}'
```
Verify all referenced iamworkin.lan hosts resolve (run from anywhere on LAN):
```bash
python scripts/check-pfsense-dns.py
# Historical filename retained. The script now calls
# https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight
# for every Certificate dnsName and Traefik Host(...) rule it finds.
python scripts/check-pfsense-dns.py --live
# Optional stronger pass when kubectl access is available; also checks
# live-cluster Certificates and IngressRoutes for drift outside manifests.
```
**Symptom if you skip this:** the Certificate resource stays `Ready: False` with `status.reason: unexpected non-ACME API error: context deadline exceeded`. Recovery requires `kubectl -n <ns> delete order <order-name>` after adding the DNS to bypass cert-manager's backoff.
### 2. Create the app manifest
Create `apps/<name>/<name>.yaml` containing the Namespace, Deployment, Service, Certificate, and IngressRoute. Reference an existing directory (e.g. `apps/fc-messageboard/`) for the canonical shape.
Conventions:
- `Namespace` has label `app.kubernetes.io/part-of: bluejay-infra`
- `Deployment.spec.selector.matchLabels` and `Service.spec.selector` MUST use the same label key. The historical convention here is `app: <name>` (not `app.kubernetes.io/name`) — don't mix.
- Image: `localhost/<name>:v<YYYYMMDD><HHMM>`, `imagePullPolicy: Never`. Import the image to every RKE2 node (server + both agents) via `ctr images import` before applying — pods schedule anywhere.
- If the app persists local state (SQLite, uploads), declare the `PersistentVolumeClaim` here with `storageClassName: longhorn` and `accessModes: [ReadWriteOnce]`. Add `strategy.type: Recreate` to the Deployment — RWO PVC blocks rolling updates.
- Probes: use `tcpSocket` if the app has middleware that intercepts unauth requests (returns 404/401 for `/health`). Otherwise prefer `httpGet` against whatever the app exposes (verify the path isn't gated by auth).
- Certificate: `issuerRef.name: step-ca-acme`, `issuerRef.kind: ClusterIssuer`. `dnsNames` must match the hostname you created in FlowerCore.DNS in step 1.
### 3. Commit & push
```bash
git add apps/<name>/
git commit -m "<name>: initial deployment"
git push
```
ArgoCD's `ApplicationSet` picks up the new directory within ~3 minutes and creates `infra-<name>` with auto-sync + self-heal enabled.
### 4. Verify
```bash
# From noc1
fcadmin_ssh noc1 '
kubectl -n argocd get application infra-<name>
kubectl -n <ns> get certificate,pod
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://<name>.iamworkin.lan/
'
```
Certificate should be `Ready: True` within ~60s. If it stalls `False` for >2m, the pfSense DNS step got skipped — go back to step 1, then `kubectl -n <ns> delete order <order-name>` to bust the backoff.
### Pre-merge gate
Before `git push`, always run:
```bash
python scripts/check-pfsense-dns.py
```
It's a quick service-backed check that would have caught the entire 2026-04-22 cert-manager outage. Consider wiring it into a pre-commit hook or a Gitea Actions workflow.
## Retiring a service
1. `kubectl -n argocd delete application infra-<name>` (cascade deletes the K8s resources via ArgoCD finalizers)
2. `git rm -r apps/<name>/` and push
3. Remove the FlowerCore.DNS record through the UI or API, for example:
```bash
curl -sk https://dns.iamworkin.lan/api/v1/servers
curl -sk -X DELETE https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records/<yourservice>
```
## Known gotchas
- **CoreDNS template + ndots:5 collision**: inside pods, `<svc>.<ns>.svc.cluster.local` with <5 dots gets search-expanded through `iamworkin.lan` FIRST and hits the wildcard template → resolves to Traefik VIP, not the real ClusterIP. Use short service names (`<svc>`) in K8s manifests. See memory `feedback_coredns_ndots_template_collision.md`.
- **Image not on node**: pods stuck `ErrImageNeverPull` means the image wasn't imported to the node Kubernetes scheduled the pod onto. `ctr images import` on all of rke2-server, rke2-agent1, rke2-agent2.
- **StatefulSet PVC drift**: `volumeClaimTemplates` needs explicit `volumeMode: Filesystem` or ArgoCD SSA self-heals forever. See memory `feedback_argocd_statefulset_pvc_drift.md`.
- **ArgoCD must use internal Gitea URL**: `http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git`, not the external HTTPS URL (step-ca cert isn't trusted by ArgoCD). The `ApplicationSet` and any hand-created `Application` must both use the internal URL.
## References
- Cert-manager recovery playbook: `FlowerCore.Notes/memory/project_cert_manager_recovery_2026_04_22.md`
- Why pfSense DNS is required: `FlowerCore.Notes/memory/feedback_pfsense_dns_required_for_acme.md`
- Public DNS operator host: `https://dns.iamworkin.lan`
- Canonical credential helper: `FlowerCore.Notes/scripts/credential-helper.sh`
- pfSense admin automation: `FlowerCore.Notes/memory/feedback_pfsense_automation.md`

View File

@@ -1,325 +1,647 @@
# =============================================================================
# Agent Zero AI Stack — NUC Deployment (RKE2 Bare-Metal)
# =============================================================================
# Deploys: AgentZero (agent UI) on RKE2 cluster
# Ollama: edge1 Pi 5 at 10.0.57.15:11434 (qwen2.5-coder:7b, CPU)
# Target: RKE2 bare-metal cluster, namespace: agent-zero
#
# Differences from LOCAL (WSL K3s):
# - Uses Longhorn StorageClass (not local-path)
# - Connects to edge1 Pi 5 Ollama (not workstation R9700)
# - NO Anthropic API key (free/local models only)
# - NO Piper TTS or Kiwix (edge1 handles TTS, no Wikipedia needed)
# - NO hostPath volumes (no access to Windows filesystem)
# - Traefik IngressRoute for LAN access at agent-zero.iamworkin.lan
# - Knowledge base loaded via ConfigMap (not hostPath)
#
# Available Ollama models on edge1:
# - qwen2.5-coder:7b ~4.7 GB Code generation (CPU, Q4_K_M)
#
# Apply: KUBECONFIG=~/.kube/rke2.yaml kubectl apply -f agent-zero-nuc.yaml
# =============================================================================
---
apiVersion: v1
kind: Namespace
metadata:
name: agent-zero
labels:
app.kubernetes.io/part-of: agent-zero-stack
# =============================================================================
# Persistent Volume Claims (Longhorn)
# =============================================================================
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: agent-zero-data
namespace: agent-zero
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: agent-zero-knowledge
namespace: agent-zero
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
# =============================================================================
# RBAC — Give Agent Zero kubectl access to the cluster
# =============================================================================
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-zero
namespace: agent-zero
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: agent-zero-cluster-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: agent-zero
namespace: agent-zero
# =============================================================================
# Agent Zero — AI Agent Web UI (NUC Edition)
# =============================================================================
# Connects to edge1 Pi 5 Ollama (free, local models only)
# No paid API keys — uses qwen2.5-coder:7b for everything
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: agent-zero
namespace: agent-zero
labels:
app: agent-zero
annotations:
agent-zero/deployment: "nuc"
agent-zero/ollama: "edge1 Pi 5 (10.0.57.15:11434)"
spec:
replicas: 1
selector:
matchLabels:
app: agent-zero
strategy:
type: Recreate
template:
metadata:
labels:
app: agent-zero
spec:
serviceAccountName: agent-zero
containers:
- name: agent-zero
image: agent0ai/agent-zero:latest
command: ["/bin/bash", "-c"]
args:
- |
# Install kubectl if not cached
if [ -f /a0/work/kubectl ]; then
cp /a0/work/kubectl /usr/local/bin/kubectl
else
curl -sLO "https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl" && \
chmod +x kubectl && mv kubectl /usr/local/bin/kubectl && \
cp /usr/local/bin/kubectl /a0/work/kubectl
fi
# Run the original entrypoint
exec /exe/initialize.sh $BRANCH
ports:
- containerPort: 80
env:
# Agent identity
- name: AGENT_NAME
value: "Blue Jay (NUC)"
# Chat model — qwen2.5-coder:7b on edge1 Pi 5
- name: A0_SET_chat_model_provider
value: "ollama"
- name: A0_SET_chat_model_name
value: "qwen2.5-coder:7b"
- name: A0_SET_chat_model_api_base
value: "http://10.0.57.15:11434"
- name: A0_SET_chat_model_ctx_length
value: "32768"
- name: A0_SET_chat_model_kwargs
value: '{"temperature": 0, "num_ctx": 32768}'
# Utility model — same as chat (only one model available)
- name: A0_SET_util_model_provider
value: "ollama"
- name: A0_SET_util_model_name
value: "qwen2.5-coder:7b"
- name: A0_SET_util_model_api_base
value: "http://10.0.57.15:11434"
- name: A0_SET_util_model_kwargs
value: '{"num_ctx": 8192}'
# Embedding model — nomic on edge1 (if installed, fallback to none)
- name: A0_SET_embed_model_provider
value: "ollama"
- name: A0_SET_embed_model_name
value: "nomic-embed-text"
- name: A0_SET_embed_model_api_base
value: "http://10.0.57.15:11434"
# Browser model — disabled (no vision model on Pi)
- name: A0_SET_browser_model_provider
value: "ollama"
- name: A0_SET_browser_model_name
value: "qwen2.5-coder:7b"
- name: A0_SET_browser_model_api_base
value: "http://10.0.57.15:11434"
- name: A0_SET_browser_model_vision
value: "false"
# Agent profile
- name: A0_SET_agent_profile
value: "default"
# Memory settings
- name: A0_SET_memory_memorize_enabled
value: "true"
- name: A0_SET_memory_memorize_consolidation
value: "true"
- name: A0_SET_memory_memorize_replace_threshold
value: "0.85"
- name: A0_SET_memory_recall_enabled
value: "true"
# Speech-to-text disabled (no GPU for Whisper)
- name: A0_SET_stt_model_size
value: "tiny"
# Kubernetes
- name: KUBERNETES_SERVICE_HOST
value: "kubernetes.default.svc"
- name: KUBERNETES_SERVICE_PORT
value: "443"
volumeMounts:
- name: workspace
mountPath: /a0/work
- name: knowledge
mountPath: /a0/knowledge/custom/main
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "3Gi"
cpu: "2000m"
volumes:
- name: workspace
persistentVolumeClaim:
claimName: agent-zero-data
- name: knowledge
persistentVolumeClaim:
claimName: agent-zero-knowledge
---
apiVersion: v1
kind: Service
metadata:
name: agent-zero
namespace: agent-zero
spec:
type: ClusterIP
selector:
app: agent-zero
ports:
- port: 80
targetPort: 80
# =============================================================================
# Traefik IngressRoute — LAN access at agent-zero.iamworkin.lan
# =============================================================================
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: agent-zero
namespace: agent-zero
spec:
entryPoints:
- websecure
routes:
- match: Host(`agent-zero.iamworkin.lan`)
kind: Rule
services:
- name: agent-zero
port: 80
tls:
secretName: agent-zero-tls
# =============================================================================
# TLS Certificate via cert-manager (step-ca ACME)
# =============================================================================
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: agent-zero-tls
namespace: agent-zero
spec:
secretName: agent-zero-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- agent-zero.iamworkin.lan
duration: 720h
renewBefore: 240h
# =============================================================================
# NetworkPolicy — Restrict traffic
# =============================================================================
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: agent-zero-netpol
namespace: agent-zero
spec:
podSelector:
matchLabels:
app: agent-zero
policyTypes:
- Ingress
- Egress
ingress:
# Allow from Traefik
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
ports:
- port: 80
egress:
# DNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# Ollama on edge1
- to:
- ipBlock:
cidr: 10.0.57.15/32
ports:
- port: 11434
# K8s API
- to:
- ipBlock:
cidr: 10.0.56.11/32
ports:
- port: 6443
# Allow internet (for kubectl image pull, etc)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
# =============================================================================
# Agent Zero AI Stack — NUC Deployment (RKE2 Bare-Metal)
# =============================================================================
# Deploys: AgentZero (agent UI) on RKE2 cluster with Blue Jay profile
# Ollama: edge1 Pi 5 + AI HAT+ ONLY (10.0.57.17:11434).
# Workstation Ollama (BLUEJAY-WS) is intentionally NOT in the upstream —
# the workstation is private dev hardware, not a cluster dependency.
# Target: RKE2 bare-metal cluster, namespace: agent-zero
# Profile: Blue Jay (21 tools, 3 prompts, 4 extensions, theme)
#
# Differences from LOCAL (WSL K3s):
# - Uses Longhorn StorageClass (not local-path)
# - Cluster-only Ollama path (edge1) — keeps workstation private
# - NO Anthropic API key (free/local models only)
# - NO Piper TTS or Kiwix (edge1 handles TTS, no Wikipedia needed)
# - NO hostPath volumes — profile/tools/extensions loaded via ConfigMaps
# - Traefik IngressRoute for LAN access at agent-zero.iamworkin.lan
#
# ConfigMaps (defined in configmaps-bluejay.yaml):
# bluejay-tools 21 Python tool modules (~520K)
# bluejay-profile agent.json, agent.yaml, system_prompt.md (~20K)
# bluejay-prompts 3 prompt templates (~11K)
# flowercore-extensions 5 Python extension modules (~76K)
# bluejay-theme CSS theme (~7K)
#
# Apply: KUBECONFIG=~/.kube/rke2.yaml kubectl apply -f agent-zero-nuc.yaml
# =============================================================================
---
apiVersion: v1
kind: Namespace
metadata:
name: agent-zero
labels:
app.kubernetes.io/part-of: agent-zero-stack
# =============================================================================
# Persistent Volume Claims (Longhorn)
# =============================================================================
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: agent-zero-data
namespace: agent-zero
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: agent-zero-knowledge
namespace: agent-zero
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
# =============================================================================
# RBAC — Give Agent Zero kubectl access to the cluster
# =============================================================================
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-zero
namespace: agent-zero
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: agent-zero-cluster-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: agent-zero
namespace: agent-zero
# =============================================================================
# Agent Zero — AI Agent Web UI (NUC Edition, Blue Jay Profile)
# =============================================================================
# Connects directly to fc-llm-bridge for chat + internal util/embed + browser.
# Agent Zero's internal util/embed slots stay on the bridge's OpenAI-compatible
# /v1 surface, while browser + corpus-search use the Ollama-compatible /api/*
# surface through OLLAMA_HOST.
# Blue Jay profile with 21 tools, 3 prompts, 4 extensions.
---
# FC LLM Bridge API key for Agent Zero (ADR-088 chat/util/embed/browser routing).
# Syncs from 1Password item "FC LLM Bridge API Keys" (field: agent-zero-k8s).
# Consumed by chat, internal util/embed, browser, and corpus-search requests
# that traverse fc-llm-bridge.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: fc-llm-bridge-api-keys
namespace: agent-zero
spec:
itemPath: "vaults/IAmWorkin/items/FC LLM Bridge API Keys"
---
# Print.Web API key for Agent Zero's print_web.py Python tool.
# Syncs from 1Password item "Print.Web API Keys" (password field = API key).
# The print_web.py tool reads PRINT_WEB_API_KEY env var for all HTTP requests
# to the thermal print service (GET /api/mcp/tools, POST /api/print/*, etc.).
# Note: Print.Web uses the legacy REST MCP shape (/api/mcp/tools/*), not the
# streamable-http MCP protocol. The print_web Python tool bridges this gap
# and is already present in bluejay-tools ConfigMaps.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: print-web-api-keys
namespace: agent-zero
spec:
itemPath: "vaults/IAmWorkin/items/Print.Web API Keys"
---
# Knowledge MCP bearer token for the direct Agent Zero -> Knowledge.Web path.
# The 1Password item currently stores the raw token in its concealed PASSWORD
# field, which the operator syncs to Secret key `password`.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: knowledge-mcp-tokens
namespace: agent-zero
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: agent-zero
namespace: agent-zero
labels:
app: agent-zero
annotations:
agent-zero/deployment: "nuc"
agent-zero/profile: "bluejay"
agent-zero/ollama: "fc-llm-bridge fronts edge1 Pi 5 + AI HAT+ Ollama for cluster browser/corpus-search traffic; internal chat/util/embed route through the bridge's authenticated OpenAI surface"
spec:
replicas: 1
selector:
matchLabels:
app: agent-zero
strategy:
type: Recreate
template:
metadata:
labels:
app: agent-zero
spec:
serviceAccountName: agent-zero
initContainers:
# Wait for fc-llm-bridge to be reachable before starting Agent Zero.
- name: wait-for-llm-bridge
image: busybox:1.37
command: ["sh", "-c"]
args:
- |
echo "Waiting for fc-llm-bridge..."
until wget -qO- --timeout=2 http://fc-llm-bridge.fc-llm-bridge.svc:8080/healthz >/dev/null 2>&1; do
echo "fc-llm-bridge not ready yet, retrying in 5s..."
sleep 5
done
echo "fc-llm-bridge is reachable."
# Assemble the Blue Jay profile directory structure from ConfigMaps.
# ConfigMaps can't create nested dirs, so we copy into the workspace PVC.
- name: setup-bluejay
image: busybox:1.37
command: ["sh", "-c"]
args:
- |
echo "Setting up Blue Jay profile..."
# Profile root files
mkdir -p /a0/work/.bluejay/agents/bluejay/tools
mkdir -p /a0/work/.bluejay/agents/bluejay/prompts
cp /tmp/bluejay-profile/* /a0/work/.bluejay/agents/bluejay/
# Tools (split across 3 ConfigMaps to stay under K8s 262K annotation limit)
cp /tmp/bluejay-tools-a/* /a0/work/.bluejay/agents/bluejay/tools/
cp /tmp/bluejay-tools-b/* /a0/work/.bluejay/agents/bluejay/tools/
cp /tmp/bluejay-tools-c/* /a0/work/.bluejay/agents/bluejay/tools/
# Prompts
cp /tmp/bluejay-prompts/* /a0/work/.bluejay/agents/bluejay/prompts/
# Extensions
mkdir -p /a0/work/.bluejay/extensions/flowercore
cp /tmp/flowercore-extensions/* /a0/work/.bluejay/extensions/flowercore/
# Theme
mkdir -p /a0/work/.bluejay/theme
cp /tmp/bluejay-theme/* /a0/work/.bluejay/theme/
echo "Blue Jay profile ready:"
echo " Tools: $(ls /a0/work/.bluejay/agents/bluejay/tools/*.py | wc -l)"
echo " Prompts: $(ls /a0/work/.bluejay/agents/bluejay/prompts/*.md | wc -l)"
echo " Extensions: $(ls /a0/work/.bluejay/extensions/flowercore/*.py | wc -l)"
volumeMounts:
- name: workspace
mountPath: /a0/work
- name: bluejay-tools-a
mountPath: /tmp/bluejay-tools-a
- name: bluejay-tools-b
mountPath: /tmp/bluejay-tools-b
- name: bluejay-tools-c
mountPath: /tmp/bluejay-tools-c
- name: bluejay-profile
mountPath: /tmp/bluejay-profile
- name: bluejay-prompts
mountPath: /tmp/bluejay-prompts
- name: flowercore-extensions
mountPath: /tmp/flowercore-extensions
- name: bluejay-theme
mountPath: /tmp/bluejay-theme
containers:
- name: agent-zero
image: agent0ai/agent-zero:latest
command: ["/bin/bash", "-c"]
args:
- |
# Install kubectl if not cached
if [ -f /a0/work/kubectl ]; then
cp /a0/work/kubectl /usr/local/bin/kubectl
else
curl -sLO "https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl" && \
chmod +x kubectl && mv kubectl /usr/local/bin/kubectl && \
cp /usr/local/bin/kubectl /a0/work/kubectl
fi
# Link Blue Jay profile from workspace into Agent Zero's expected path
ln -sfn /a0/work/.bluejay/agents/bluejay /a0/agents/bluejay
# Write model config BEFORE initialize.sh loads it
# The _model_config plugin reads config.json (NOT config.yaml).
# chat_model: FlowerCore LLM Bridge (ADR-088) — OpenAI-compat,
# spend-tracked, tier-aliased (fc:balanced → Claude Sonnet).
# api_key comes from A0_SET_chat_model_api_key env var (overrides
# config.json). Utility + embedding stay on the authenticated
# OpenAI-compatible /v1 surface; browser and direct tool traffic
# use the bridge's Ollama-compatible root via OLLAMA_HOST.
mkdir -p /a0/usr/plugins/_model_config
cat > /a0/usr/plugins/_model_config/config.json << 'MODELCFG'
{"allow_chat_override":true,"chat_model":{"provider":"openai","name":"fc:balanced","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_history":0.7,"vision":false,"kwargs":{"temperature":0,"num_ctx":8192}},"utility_model":{"provider":"openai","name":"fc:cheap","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","ctx_length":8192,"ctx_input":0.7,"kwargs":{"num_ctx":8192}},"embedding_model":{"provider":"openai","name":"openai/fc:embedding","api_base":"http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1","kwargs":{}}}
MODELCFG
# Strip heredoc indentation
sed -i 's/^ //' /a0/usr/plugins/_model_config/config.json
# Phase 0 Chat MCP pilot: Agent Zero does not interpolate env vars
# inside A0_SET_mcp_servers JSON, so build the final JSON here from
# the secret-backed env vars before initialize.sh. Keep the local
# corpus_search.py tool mounted either way so outage fallback
# remains available even when fc_knowledge is not advertised.
export KNOWLEDGE_MCP_ENABLED=false
if [ -n "${KNOWLEDGE_MCP_BEARER_TOKEN:-}" ]; then
if curl -sf --connect-timeout 3 "${KNOWLEDGE_MCP_HEALTH_URL}" > /dev/null && \
curl -sf --connect-timeout 5 \
-H "Authorization: Bearer ${KNOWLEDGE_MCP_BEARER_TOKEN}" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"fc-knowledge-bootstrap","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"agent-zero-bootstrap","version":"1.0"}}}' \
"${KNOWLEDGE_MCP_URL}" > /dev/null; then
export KNOWLEDGE_MCP_ENABLED=true
echo "fc_knowledge enabled from ${KNOWLEDGE_MCP_URL}."
else
echo "fc_knowledge unavailable or unauthorized; keeping local corpus_search.py as the fallback path."
fi
else
echo "fc_knowledge token missing; keeping local corpus_search.py as the fallback path."
fi
export A0_SET_mcp_servers="$(
python3 -c 'import json, os; servers = {}; chat_key = os.getenv("CHAT_MCP_API_KEY"); knowledge_enabled = os.getenv("KNOWLEDGE_MCP_ENABLED", "false").lower() == "true"; token = os.getenv("KNOWLEDGE_MCP_BEARER_TOKEN", "") if knowledge_enabled else ""; chat_key and servers.setdefault("fc_chat", {"type": "streamable-http", "url": "http://chat-web.fc-chat.svc/mcp", "headers": {"X-Api-Key": chat_key}}); token and servers.setdefault("fc_knowledge", {"type": "streamable-http", "url": os.getenv("KNOWLEDGE_MCP_URL", "http://knowledge-web.knowledge.svc/mcp"), "headers": {"Authorization": f"Bearer {token}"}}); print(json.dumps({"mcpServers": servers}, separators=(",", ":")))'
)"
# Run the original entrypoint
exec /exe/initialize.sh $BRANCH
ports:
- containerPort: 80
env:
# Agent identity
- name: AGENT_NAME
value: "Blue Jay (NUC)"
# Chat model — routed through FlowerCore LLM Bridge (ADR-088)
# so spend is tracked and tier aliases (fc:cheap/fc:balanced/fc:deep)
# dispatch to Ollama or Anthropic via a single OpenAI-compat endpoint.
# Internal utility + embedding use the authenticated OpenAI surface,
# while browser/corpus-search use the bridge's Ollama-compatible
# endpoints so Agent Zero no longer needs a local proxy sidecar.
- name: A0_SET_chat_model_provider
value: "openai"
- name: A0_SET_chat_model_name
value: "fc:balanced"
- name: A0_SET_chat_model_api_base
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
- name: A0_SET_chat_model_api_key
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: agent-zero-k8s
# Agent Zero's runtime still resolves provider keys from the
# provider-level env names (models.get_api_key -> OPENAI_API_KEY /
# API_KEY_OPENAI), not the slot-scoped A0_SET_* value alone.
# Mirror the same secret here so real public chat runs can reach
# the fc-llm-bridge chat_model path instead of failing before MCP.
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: agent-zero-k8s
- name: FC_LLM_BRIDGE_API_KEY
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: agent-zero-k8s
- name: A0_SET_chat_model_ctx_length
value: "8192"
- name: A0_SET_chat_model_kwargs
value: '{"temperature": 0, "num_ctx": 8192}'
# Utility model — fast small helper tier through the OpenAI surface
- name: A0_SET_util_model_provider
value: "openai"
- name: A0_SET_util_model_name
value: "fc:cheap"
- name: A0_SET_util_model_api_base
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
- name: A0_SET_util_model_kwargs
value: '{"num_ctx": 2048}'
# Embedding model — authenticated bridge alias to nomic-embed-text.
# LiteLLM's embedding() path needs an explicit provider prefix here
# even though the chat slot can use bare fc:* aliases.
- name: A0_SET_embed_model_provider
value: "openai"
- name: A0_SET_embed_model_name
value: "openai/fc:embedding"
- name: A0_SET_embed_model_api_base
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080/v1"
# Browser model — small Gemma candidate through the same proxy
- name: A0_SET_browser_model_provider
value: "ollama"
- name: A0_SET_browser_model_name
value: "gemma3:4b"
- name: A0_SET_browser_model_api_base
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
- name: A0_SET_browser_model_api_key
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: agent-zero-k8s
- name: A0_SET_browser_model_vision
value: "true"
- name: OLLAMA_HOST
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
- name: FLOWERCORE_AGENTZERO_OLLAMA_URL
value: "http://fc-llm-bridge.fc-llm-bridge.svc:8080"
# Agent profile — Blue Jay personality, tools, and system prompt
- name: A0_SET_agent_profile
value: "bluejay"
# Memory settings
- name: A0_SET_memory_memorize_enabled
value: "true"
- name: A0_SET_memory_memorize_consolidation
value: "true"
- name: A0_SET_memory_memorize_replace_threshold
value: "0.85"
- name: A0_SET_memory_recall_enabled
value: "true"
# Speech-to-text disabled (no GPU for Whisper)
- name: A0_SET_stt_model_size
value: "tiny"
# FlowerCore.Chat MCP pilot (Phase 0)
- name: CHAT_MCP_API_KEY
valueFrom:
secretKeyRef:
name: chat-mcp-api-key
key: api-key
optional: true
# FlowerCore.Knowledge MCP Phase 1 — direct Agent Zero client path.
# Probe /healthz first, then try an authenticated initialize call.
# If either fails, Agent Zero boots without fc_knowledge and keeps
# the local corpus_search.py tool as the outage-safe path.
- name: KNOWLEDGE_MCP_URL
value: "http://knowledge-web.knowledge.svc/mcp"
- name: KNOWLEDGE_MCP_HEALTH_URL
value: "http://knowledge-web.knowledge.svc/healthz"
- name: KNOWLEDGE_MCP_BEARER_TOKEN
valueFrom:
secretKeyRef:
name: knowledge-mcp-tokens
key: password
# Print.Web — Thermal printer service on edge2.
# PRINT_WEB_URL: internal HTTP (bypasses Traefik TLS — print_web.py
# runs in-cluster and can reach edge2 directly on the PROD VLAN).
# PRINT_WEB_API_KEY: from 1Password "Print.Web API Keys" password field,
# synced by the print-web-api-keys OnePasswordItem CRD above.
# The print_web.py Python tool reads both env vars for all HTTP calls.
- name: PRINT_WEB_URL
value: "http://10.0.57.16:5200"
- name: PRINT_WEB_API_KEY
valueFrom:
secretKeyRef:
name: print-web-api-keys
key: password
# Intranet search — use in-cluster HTTP (no step-ca TLS needed)
# corpus_search.py reads FLOWERCORE_FLEET_VECTOR_DIR but that mount is not
# on the cluster yet (BLUEJAY-WS only). The tool gracefully returns a
# "no DB found" message with rebuild instructions rather than crashing.
- name: FLOWERCORE_INTRANET_URL
value: "http://intranet-web.intranet.svc:5300"
# Kubernetes
- name: KUBERNETES_SERVICE_HOST
value: "kubernetes.default.svc"
- name: KUBERNETES_SERVICE_PORT
value: "443"
volumeMounts:
- name: workspace
mountPath: /a0/work
- name: knowledge
mountPath: /a0/knowledge/custom/main
- name: flowercore-extensions
mountPath: /a0/extensions/flowercore
readOnly: true
- name: bluejay-theme
mountPath: /a0/webui/static/css/custom
readOnly: true
startupProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 18
livenessProbe:
httpGet:
path: /
port: 80
periodSeconds: 30
failureThreshold: 3
readinessProbe:
exec:
command:
- /bin/bash
- -c
- "curl -sf http://localhost:80/ > /dev/null && curl -sf --connect-timeout 3 http://fc-llm-bridge.fc-llm-bridge.svc:8080/healthz > /dev/null"
periodSeconds: 30
failureThreshold: 2
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "3Gi"
cpu: "2000m"
volumes:
- name: workspace
persistentVolumeClaim:
claimName: agent-zero-data
- name: knowledge
persistentVolumeClaim:
claimName: agent-zero-knowledge
- name: bluejay-tools-a
configMap:
name: bluejay-tools-a
- name: bluejay-tools-b
configMap:
name: bluejay-tools-b
- name: bluejay-tools-c
configMap:
name: bluejay-tools-c
- name: bluejay-profile
configMap:
name: bluejay-profile
- name: bluejay-prompts
configMap:
name: bluejay-prompts
- name: flowercore-extensions
configMap:
name: flowercore-extensions
- name: bluejay-theme
configMap:
name: bluejay-theme
---
apiVersion: v1
kind: Service
metadata:
name: agent-zero
namespace: agent-zero
spec:
type: ClusterIP
selector:
app: agent-zero
ports:
- port: 80
targetPort: 80
# =============================================================================
# Traefik IngressRoute — LAN access at agent-zero.iamworkin.lan
# =============================================================================
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: agent-zero
namespace: agent-zero
spec:
entryPoints:
- websecure
routes:
- match: Host(`agent-zero.iamworkin.lan`)
kind: Rule
services:
- name: agent-zero
port: 80
tls:
secretName: agent-zero-tls
# =============================================================================
# TLS Certificate via cert-manager (step-ca ACME)
# =============================================================================
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: agent-zero-tls
namespace: agent-zero
spec:
secretName: agent-zero-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- agent-zero.iamworkin.lan
duration: 720h
renewBefore: 240h
# =============================================================================
# NetworkPolicy — Restrict traffic
# =============================================================================
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: agent-zero-netpol
namespace: agent-zero
spec:
podSelector:
matchLabels:
app: agent-zero
policyTypes:
- Ingress
- Egress
ingress:
# Allow from Traefik
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
ports:
- port: 80
# Allow from monitoring (blackbox probe)
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
ports:
- port: 80
egress:
# DNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# Print.Web on edge2
- to:
- ipBlock:
cidr: 10.0.57.16/32
ports:
- port: 5200
# K8s API
- to:
- ipBlock:
cidr: 10.0.56.11/32
ports:
- port: 6443
# FlowerCore LLM Bridge (ADR-088 chat_model routing) — ClusterIP service
# in the fc-llm-bridge namespace on port 8080.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-llm-bridge
ports:
- port: 8080
protocol: TCP
# FlowerCore.Chat MCP (Phase 0 pilot) — use the in-cluster chat-web
# service instead of the public Traefik VIP so MCP traffic stays inside
# the cluster and survives the private-range egress denylist.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-chat
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP
# FlowerCore.Knowledge MCP (Phase 1) — in-cluster direct route with
# anonymous /healthz probe plus authenticated /mcp initialize/tool calls.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: knowledge
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP
# Intranet search API — use in-cluster svc so traffic stays inside
# the cluster and is not blocked by the private-range egress denylist.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: intranet
ports:
- port: 5300
protocol: TCP
# Allow internet (for kubectl image pull, etc)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16

File diff suppressed because it is too large Load Diff

View File

@@ -96,7 +96,9 @@ data:
allow=ulaw
allow=alaw
direct_media=no
dtmf_mode=inband
; Yealink provisioning sends RFC2833/RFC4733 DTMF (payload 101).
; Keep the PBX template aligned so physical desk phones emit ARI DTMF events.
dtmf_mode=rfc4733
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
@@ -173,6 +175,83 @@ data:
remove_existing=yes
qualify_frequency=60
; Test endpoints 901-904 for softphone proof
[test-endpoint](!)
type=endpoint
context=from-internal
transport=transport-udp
disallow=all
allow=ulaw
allow=alaw
direct_media=no
rtp_symmetric=yes
force_rport=yes
rewrite_contact=yes
[901](test-endpoint)
auth=auth901
aors=901
callerid="Proof Caller" <901>
[auth901]
type=auth
auth_type=userpass
username=901
password=test-sip-secret-901
[901]
type=aor
max_contacts=1
remove_existing=yes
[902](test-endpoint)
auth=auth902
aors=902
callerid="Proof Callee" <902>
[auth902]
type=auth
auth_type=userpass
username=902
password=test-sip-secret-901
[902]
type=aor
max_contacts=1
remove_existing=yes
[903](test-endpoint)
auth=auth903
aors=903
callerid="Proof Endpoint 3" <903>
[auth903]
type=auth
auth_type=userpass
username=903
password=test-sip-secret-901
[903]
type=aor
max_contacts=1
remove_existing=yes
[904](test-endpoint)
auth=auth904
aors=904
callerid="Proof Endpoint 4" <904>
[auth904]
type=auth
auth_type=userpass
username=904
password=test-sip-secret-901
[904]
type=aor
max_contacts=1
remove_existing=yes
extensions.conf: |
[general]
static=yes
@@ -195,6 +274,32 @@ data:
exten => _1XX,1,Dial(PJSIP/${EXTEN},30)
same => n,Hangup()
; Softphone proof endpoints and utility extensions
exten => _9XX,1,NoOp(Proof call to ${EXTEN})
same => n,Dial(PJSIP/${EXTEN},30)
same => n,Hangup()
exten => 999,1,Answer()
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Hangup()
exten => 998,1,Answer()
same => n,Milliwatt()
same => n,Hangup()
exten => 997,1,Answer()
same => n,Wait(0.5)
same => n,Playback(hello-world)
same => n,Wait(1)
same => n,Hangup()
exten => 996,1,Answer()
same => n,Wait(0.5)
same => n,Read(DIGITS,,4,,,5)
same => n,SayDigits(${DIGITS})
same => n,Hangup()
; Outbound via Twilio SIP trunk (11-digit US)
exten => _1NXXNXXXXXX,1,Set(CALLERID(num)=+13202332529)
same => n,Dial(PJSIP/+${EXTEN}@twilio-trunk,60)
@@ -209,6 +314,13 @@ data:
exten => *100,1,Stasis(flowercore-pbx,internal,ivr)
same => n,Hangup()
; Test-only entry into the Victory Day workflow (DID +15074618329).
; Used by live SIP AATs to exercise the VDAY Fun Menu + AsteriskGameHandler
; path without dialing in over Twilio. Mnemonic: *832 = "V-D-A" (8-3-2).
exten => *832,1,NoOp(Test entry: Victory Day workflow via AAT)
same => n,Stasis(flowercore-pbx,inbound-pstn,+15074618329)
same => n,Hangup()
; Star codes routed to FlowerCore Stasis app for handling
exten => *0,1,Stasis(flowercore-pbx,starcode,*0)
same => n,Hangup()

View File

@@ -1,150 +1,188 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: asterisk
namespace: telephony
labels:
app: asterisk
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: asterisk
template:
metadata:
labels:
app: asterisk
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
securityContext:
fsGroup: 0
initContainers:
- name: install-sounds
image: busybox:latest
command:
- sh
- -c
- |
mkdir -p /sounds/en &&
wget -qO- http://downloads.asterisk.org/pub/telephony/sounds/asterisk-core-sounds-en-ulaw-current.tar.gz | tar xz -C /sounds/en/ &&
wget -qO- http://downloads.asterisk.org/pub/telephony/sounds/asterisk-extra-sounds-en-ulaw-current.tar.gz | tar xz -C /sounds/en/ &&
echo "Sound files installed: $(find /sounds/en -type f | wc -l) files"
volumeMounts:
- name: sounds
mountPath: /sounds/en
containers:
- name: asterisk
image: localhost/andrius/asterisk:latest
imagePullPolicy: Never
ports:
- name: sip-udp
containerPort: 5060
protocol: UDP
- name: sip-tcp
containerPort: 5060
protocol: TCP
- name: ari
containerPort: 8088
protocol: TCP
volumeMounts:
- name: config-modules
mountPath: /etc/asterisk/modules.conf
subPath: modules.conf
- name: config-http
mountPath: /etc/asterisk/http.conf
subPath: http.conf
- name: config-ari
mountPath: /etc/asterisk/ari.conf
subPath: ari.conf
- name: config-manager
mountPath: /etc/asterisk/manager.conf
subPath: manager.conf
- name: config-pjsip
mountPath: /etc/asterisk/pjsip.conf
subPath: pjsip.conf
- name: config-extensions
mountPath: /etc/asterisk/extensions.conf
subPath: extensions.conf
- name: config-rtp
mountPath: /etc/asterisk/rtp.conf
subPath: rtp.conf
- name: asterisk-data
mountPath: /var/spool/asterisk
- name: asterisk-logs
mountPath: /var/log/asterisk
- name: sounds
mountPath: /var/lib/asterisk/sounds/en
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
livenessProbe:
tcpSocket:
port: 8088
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /ari/asterisk/info
port: 8088
httpHeaders:
- name: Authorization
value: "Basic Zmxvd2VyY29yZTpibHVlamF5LWFzdGVyaXNrLWFyaQ=="
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: config-modules
configMap:
name: asterisk-config
items:
- key: modules.conf
path: modules.conf
- name: config-http
configMap:
name: asterisk-config
items:
- key: http.conf
path: http.conf
- name: config-ari
configMap:
name: asterisk-config
items:
- key: ari.conf
path: ari.conf
- name: config-manager
configMap:
name: asterisk-config
items:
- key: manager.conf
path: manager.conf
- name: config-pjsip
configMap:
name: asterisk-config
items:
- key: pjsip.conf
path: pjsip.conf
- name: config-extensions
configMap:
name: asterisk-config
items:
- key: extensions.conf
path: extensions.conf
- name: config-rtp
configMap:
name: asterisk-config
items:
- key: rtp.conf
path: rtp.conf
- name: asterisk-data
persistentVolumeClaim:
claimName: asterisk-data
- name: asterisk-logs
emptyDir: {}
- name: sounds
emptyDir: {}
apiVersion: apps/v1
kind: Deployment
metadata:
name: asterisk
namespace: telephony
labels:
app: asterisk
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: asterisk
template:
metadata:
labels:
app: asterisk
spec:
nodeSelector:
kubernetes.io/hostname: rke2-agent1
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
securityContext:
fsGroup: 0
# CoreDNS in this cluster has an iamworkin.lan wildcard that catches
# any unresolved name and returns 10.0.56.200 (Traefik VIP), which
# means downloads.asterisk.org inside the pod resolves to Traefik and
# returns 404. Pin the real address so the init container can fetch
# the sounds tarball.
hostAliases:
- ip: 165.22.184.19
hostnames:
- downloads.asterisk.org
initContainers:
- name: install-sounds
# Downloads Asterisk core sounds (en, ulaw) into the sounds emptyDir
# volume so the base Asterisk image (which ships no sounds) can play
# vm-advopts, vm-goodbye, digits/*, characters/*, beep, etc. Skips
# the download if the directory already contains sound files —
# re-running the pod after a hot image reload reuses the unpack.
image: alpine:3.20
command:
- sh
- -c
- |
set -eu
if [ -f /sounds/en/vm-goodbye.ulaw ] || [ -f /sounds/en/vm-goodbye.gsm ]; then
echo "Sounds already present — skipping download."
exit 0
fi
echo "Installing curl + tar..."
apk add --no-cache curl tar gzip >/dev/null
cd /tmp
echo "Downloading Asterisk core sounds (en, ulaw) 1.6.1..."
# -k: cluster egress goes through a step-ca MITM for outbound TLS
# that this pod does not trust. The tarball is a public artifact —
# integrity is checked downstream by Asterisk at playback time.
curl -fksSLO https://downloads.asterisk.org/pub/telephony/sounds/releases/asterisk-core-sounds-en-ulaw-1.6.1.tar.gz
echo "Extracting to /sounds/en ..."
mkdir -p /sounds/en
tar -xzf asterisk-core-sounds-en-ulaw-1.6.1.tar.gz -C /sounds/en
echo "Done — $(ls /sounds/en | wc -l) files installed."
volumeMounts:
- name: sounds
mountPath: /sounds/en
containers:
- name: asterisk
image: localhost/andrius/asterisk:latest
imagePullPolicy: Never
ports:
- name: sip-udp
containerPort: 5060
protocol: UDP
- name: sip-tcp
containerPort: 5060
protocol: TCP
- name: ari
containerPort: 8088
protocol: TCP
volumeMounts:
- name: config-modules
mountPath: /etc/asterisk/modules.conf
subPath: modules.conf
- name: config-http
mountPath: /etc/asterisk/http.conf
subPath: http.conf
- name: config-ari
mountPath: /etc/asterisk/ari.conf
subPath: ari.conf
- name: config-manager
mountPath: /etc/asterisk/manager.conf
subPath: manager.conf
- name: config-pjsip
mountPath: /etc/asterisk/pjsip.conf
subPath: pjsip.conf
- name: config-extensions
mountPath: /etc/asterisk/extensions.conf
subPath: extensions.conf
- name: config-rtp
mountPath: /etc/asterisk/rtp.conf
subPath: rtp.conf
- name: asterisk-data
mountPath: /var/spool/asterisk
- name: asterisk-logs
mountPath: /var/log/asterisk
- name: sounds
mountPath: /var/lib/asterisk/sounds/en
# Shared TTS audio — telephony-web writes .sln16 files here (as
# /shared-tts), Asterisk plays them via `sound:tts/<name>` which
# resolves to this mount. Both pods are pinned to rke2-agent1.
- name: shared-tts
mountPath: /var/lib/asterisk/sounds/tts
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
livenessProbe:
tcpSocket:
port: 8088
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /ari/asterisk/info
port: 8088
httpHeaders:
- name: Authorization
value: "Basic Zmxvd2VyY29yZTpibHVlamF5LWFzdGVyaXNrLWFyaQ=="
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: config-modules
configMap:
name: asterisk-config
items:
- key: modules.conf
path: modules.conf
- name: config-http
configMap:
name: asterisk-config
items:
- key: http.conf
path: http.conf
- name: config-ari
configMap:
name: asterisk-config
items:
- key: ari.conf
path: ari.conf
- name: config-manager
configMap:
name: asterisk-config
items:
- key: manager.conf
path: manager.conf
- name: config-pjsip
configMap:
name: asterisk-config
items:
- key: pjsip.conf
path: pjsip.conf
- name: config-extensions
configMap:
name: asterisk-config
items:
- key: extensions.conf
path: extensions.conf
- name: config-rtp
configMap:
name: asterisk-config
items:
- key: rtp.conf
path: rtp.conf
- name: asterisk-data
persistentVolumeClaim:
claimName: asterisk-data
- name: asterisk-logs
emptyDir: {}
- name: sounds
emptyDir: {}
- name: shared-tts
hostPath:
path: /tmp/tts-audio
type: DirectoryOrCreate

View File

@@ -1,40 +1,40 @@
apiVersion: v1
kind: Service
metadata:
name: asterisk-sip
namespace: telephony
labels:
app: asterisk
annotations:
metallb.universe.tf/loadBalancerIPs: "10.0.56.207"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: asterisk
ports:
- name: sip-udp
port: 5060
targetPort: 5060
protocol: UDP
- name: sip-tcp
port: 5060
targetPort: 5060
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: asterisk-ari
namespace: telephony
labels:
app: asterisk
spec:
type: ClusterIP
selector:
app: asterisk
ports:
- name: ari
port: 8088
targetPort: 8088
protocol: TCP
apiVersion: v1
kind: Service
metadata:
name: asterisk-sip
namespace: telephony
labels:
app: asterisk
annotations:
metallb.universe.tf/loadBalancerIPs: "10.0.56.207"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: asterisk
ports:
- name: sip-udp
port: 5060
targetPort: 5060
protocol: UDP
- name: sip-tcp
port: 5060
targetPort: 5060
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: asterisk-ari
namespace: telephony
labels:
app: asterisk
spec:
type: ClusterIP
selector:
app: asterisk
ports:
- name: ari
port: 8088
targetPort: 8088
protocol: TCP

View File

@@ -0,0 +1,106 @@
# edge2 Services — Traefik IngressRoutes for FlowerCore Print.Web on edge2
# Proxies print.iamworkin.lan to edge2 (10.0.57.16:5200) via headless Service
# + manual Endpoints (same K8s external-proxy pattern as noc-services).
#
# Print.Web has its own X-Api-Key authentication and exposes anonymous
# endpoints for the bookmarklet / Python CLI / cups-notifier flow, so no
# Traefik basicAuth middleware is wired here.
#
# ArgoCD managed - BlueJay Lab
---
apiVersion: v1
kind: Namespace
metadata:
name: edge2-proxy
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# ============================================================
# Print.Web - edge2:5200 (FlowerCore.Print.Web on Pi 4)
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: print-web-external
namespace: edge2-proxy
spec:
ports:
- port: 5200
targetPort: 5200
name: http
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: print-web-external
namespace: edge2-proxy
subsets:
- addresses:
- ip: 10.0.57.16
ports:
- port: 5200
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: print-web-tls
namespace: edge2-proxy
spec:
secretName: print-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- print.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: print-web
namespace: edge2-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`print.iamworkin.lan`)
services:
- name: print-web-external
port: 5200
tls:
secretName: print-web-tls
---
# NetworkPolicy: allow Traefik ingress, allow egress to edge2 + DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: edge2-proxy-netpol
namespace: edge2-proxy
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
egress:
- to:
- ipBlock:
cidr: 10.0.57.16/32
ports:
- port: 5200
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP

32
apps/fc-chat/fc-chat.yaml Normal file
View File

@@ -0,0 +1,32 @@
# FlowerCore Chat — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: chat-web-tls
namespace: fc-chat
spec:
secretName: chat-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- chat.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: chat-web
namespace: fc-chat
spec:
entryPoints:
- websecure
routes:
- match: Host(`chat.iamworkin.lan`)
kind: Rule
services:
- name: chat-web
port: 80
tls:
secretName: chat-web-tls

View File

@@ -0,0 +1,40 @@
# FlowerCore Remote Desktop — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: remotedesktop-web-tls
namespace: fc-desktop
spec:
secretName: remotedesktop-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- desktop.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: remotedesktop-web
namespace: fc-desktop
spec:
entryPoints:
- websecure
routes:
# Host-level catch-all for desktop.iamworkin.lan. The /guacamole
# path-prefix match lives in apps/guacamole/guacamole.yaml as a
# separate IngressRoute in the guacamole namespace — the cluster
# Traefik disallows cross-namespace service refs, so the PathPrefix
# rule can't sit here. Traefik's router matching precedence gives
# longer/more-specific rules priority automatically, so as long as
# the guacamole IngressRoute exists it takes /guacamole traffic
# before this catch-all sees it.
- match: Host(`desktop.iamworkin.lan`)
kind: Rule
services:
- name: remotedesktop-web
port: 8080
tls:
secretName: remotedesktop-web-tls

View File

@@ -0,0 +1,105 @@
# fc-distribution — staged deployment (Phase 1, USB provisioning)
**Status:** manifests staged, **NOT YET APPLIED**. Image must be built +
imported and signing 1Password items confirmed before `git push`.
- Architecture: [`../../../FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md`](../../../FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md)
- Repo: `D:\git\FlowerCore\FlowerCore.Distribution\` (`README.md`, `CLAUDE.md`)
- Shared lib: `FlowerCore.Common` -> `FlowerCore.Shared.Distribution`
`FlowerCore.Distribution` publishes signed edition manifests (ECDSA P-256
over canonical JSON) and serves the SHA-256 content-addressed blob store
that USB builders pull from. The verifier embeds the `IAmWorkin ACME CA
Root CA` as the trust anchor; per-edition leaf signing material lives in
1Password and is mounted into the pod read-only.
## Deployment order (do NOT skip / reorder)
### 1. FlowerCore.DNS preflight — VERIFIED 2026-04-23
`dist.iamworkin.lan` already resolves to `10.0.56.200`, but keep the
FlowerCore.DNS preflight green before push:
```bash
curl -sk "https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight?hostname=dist.iamworkin.lan"
# Expect: "resolvable": true
python bluejay-infra/scripts/check-pfsense-dns.py
# Historical filename retained; implementation now calls FlowerCore.DNS
# resolve-preflight instead of raw resolver lookups.
```
If the record ever disappears, recreate it through FlowerCore.DNS before
push/apply:
```bash
curl -sk https://dns.iamworkin.lan/api/v1/servers
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
-H "Content-Type: application/json" \
-d '{"name":"dist","type":"A","data":"10.0.56.200","ttl":300}'
```
If this is missing, cert-manager HTTP-01 will silently back off ~2h. See
memory `feedback_pfsense_dns_required_for_acme.md`.
### 2. 1Password items required in vault `IAmWorkin`
| Item title | Item id | Used as |
|---|---|---|
| `FlowerCore Code Signing CA` | (existing) | Informational handle only — root CA is baked into the image at build time, not mounted |
| `FlowerCore Edition Signing Key - edition:kiosk-standard` | `3hf33egdvnni6jyuws3r737mqe` | Mounted at `/signing/kiosk-standard/` |
| `FlowerCore Edition Signing Key - edition:aistation-field` | `ccxrtsan5samfq4pfuczymacrq` | Mounted at `/signing/aistation-field/` |
Each edition item must publish three field labels (the operator turns
field labels into Secret keys verbatim):
- `certificate.pem` — leaf certificate
- `private-key.pem` — ECDSA P-256 private key
- `chain.pem` — leaf + intermediate (referenced by the env var as the
cert-path; the verifier uses this for signature path validation)
### 3. Build + import the image to rke2-server
The Pod is pinned to `rke2-server` because the Synology NFS export
`/volume1/kubernetes` only allows that node. Importing to the agents is
optional until the ACL is widened.
```bash
# From BLUEJAY-WS, in D:\git\FlowerCore\FlowerCore.Distribution
TAG="v$(date +%Y%m%d%H%M)"
dotnet.exe publish -c Release -o deploy/app \
src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
podman build -t localhost/fc-distribution:$TAG -f deploy/Dockerfile.deploy deploy
podman save localhost/fc-distribution:$TAG -o /tmp/fc-distribution.tar
scp /tmp/fc-distribution.tar rke2-server:/tmp/
ssh rke2-server "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-distribution.tar"
```
### 4. Bump the image tag + push
Edit `fc-distribution.yaml`, replace `localhost/fc-distribution:v202604231530`
with the tag from step 3, then:
```bash
cd D:/git/FlowerCore/bluejay-infra
python scripts/check-pfsense-dns.py
git add apps/fc-distribution/
git commit -m "feat(fc-distribution): deploy Phase 1 manifest publisher"
git push
```
ArgoCD picks up within ~3 minutes and creates `infra-fc-distribution`.
### 5. Verify
```bash
fcadmin_ssh noc1 '
kubectl -n argocd get application infra-fc-distribution
kubectl -n fc-distribution get certificate,pod,secret
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://dist.iamworkin.lan/healthz
'
```
Expect: Certificate `Ready: True` within ~60s, `/healthz` HTTP 200, both
`edition-kiosk-standard` and `edition-aistation-field` Secrets present
with `certificate.pem`, `private-key.pem`, `chain.pem` keys.

View File

@@ -0,0 +1,347 @@
# FlowerCore.Distribution — edition manifest publisher + content-addressed blob store.
# Phase 1 of the USB provisioning architecture: signed edition manifests
# (ECDSA P-256 over canonical JSON) published per edition, plus a SHA-256
# content-addressed blob store that USB builders pull from.
#
# Architecture: FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md
# Repo: FlowerCore.Distribution/{README.md,CLAUDE.md}
# Shared lib: FlowerCore.Common -> FlowerCore.Shared.Distribution
# (manifest schema, canonical JSON, ECDSA P-256 sign/verify)
#
# Deployment order (see bluejay-infra/README.md and apps/fc-distribution/README.md):
# 1. pfSense Unbound DNS override for dist.iamworkin.lan -> 10.0.56.200
# (DONE 2026-04-23 — verify with `python bluejay-infra/scripts/check-pfsense-dns.py`).
# 2. 1Password items must exist in vault `IAmWorkin`:
# - `FlowerCore Code Signing CA` (informational)
# - `FlowerCore Edition Signing Key - edition:kiosk-standard` (3hf33egdvnni6jyuws3r737mqe)
# - `FlowerCore Edition Signing Key - edition:aistation-field` (ccxrtsan5samfq4pfuczymacrq)
# Each edition item is expected to publish three field labels:
# certificate.pem, private-key.pem, chain.pem
# 3. Synology NFS export `/volume1/kubernetes` is currently restricted to
# rke2-server (10.0.56.11). Pod is pinned via nodeSelector below. The
# app writes to subPaths `distribution/data` and `distribution/blobs`.
# 4. Build + import image: localhost/fc-distribution:v<YYYYMMDD><HHMM>
# Import to rke2-server via `ctr images import` (NFS-pinned, no need
# for the agents until ACL is widened — see guacamole pattern).
# 5. Bump the image tag below and git push; ArgoCD ApplicationSet picks up
# within ~3 minutes and creates `infra-fc-distribution`.
#
# NOTE on the root trust anchor:
# The verifier needs an embedded root CA (`IAmWorkin ACME CA Root CA`).
# That root is shipped INSIDE the published image (Phase 2 build step
# bakes it into the bundle), NOT mounted from a Secret here. The
# `codesigning-root-cert` OnePasswordItem below is informational only —
# it gives operators a quick handle to the CA item from the cluster.
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-distribution
labels:
app.kubernetes.io/part-of: flowercore
---
# Informational handle to the FlowerCore Code Signing CA item in 1Password.
# Not consumed by the pod at runtime — the root trust anchor is baked into
# the published image. Operators can `kubectl -n fc-distribution get secret
# codesigning-root-cert` to discover the CA item URL/admin handle.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: codesigning-root-cert
namespace: fc-distribution
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Code Signing CA"
---
# Edition signing key + leaf cert + chain for edition:kiosk-standard.
# 1Password item id: 3hf33egdvnni6jyuws3r737mqe
# Operator syncs each field to a Secret key of the same name. Mounted
# read-only at /signing/kiosk-standard inside the pod.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: edition-kiosk-standard
namespace: fc-distribution
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Edition Signing Key - edition:kiosk-standard"
---
# Edition signing key + leaf cert + chain for edition:aistation-field.
# 1Password item id: ccxrtsan5samfq4pfuczymacrq
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: edition-aistation-field
namespace: fc-distribution
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Edition Signing Key - edition:aistation-field"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-distribution
namespace: fc-distribution
labels:
app.kubernetes.io/name: fc-distribution
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
# NFS-backed SQLite + blob store on a single node. Recreate avoids any
# multi-attach overlap on the same NFS subPath during rollout.
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: fc-distribution
template:
metadata:
labels:
app.kubernetes.io/name: fc-distribution
app.kubernetes.io/part-of: flowercore
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
# Synology NFS export `/volume1/kubernetes` ACL only allows rke2-server
# (10.0.56.11) right now. Until the ACL is widened in DSM (admin only),
# this Pod must run on rke2-server or NFS mounts will be access-denied.
nodeSelector:
kubernetes.io/hostname: rke2-server
securityContext:
runAsNonRoot: true
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
# Placeholder tag — bump to the image you built + imported to
# rke2-server before applying. Build with:
# dotnet.exe publish -c Release -o deploy/app \
# src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
# podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy
image: localhost/fc-distribution:v202604240010
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
# SQLite connection (catalog + data-protection keys via FlowerCoreDbContext).
# Read by Data/DatabaseProviderExtensions.cs in precedence order; Sqlite key wins.
- name: FlowerCore__Database__Provider
value: "Sqlite"
- name: FlowerCore__Database__ConnectionStrings__Sqlite
value: "Data Source=/data/distribution.db"
# Content-addressed blob root (SHA-256 sharded on disk).
# Bound by Services/NfsPvcBlobProvider.cs under FlowerCore:Distribution:Blobs.
- name: FlowerCore__Distribution__Blobs__Root
value: "/blobs"
# Per-edition signing material — paths into the read-only
# secret mounts below. Field labels in 1Password (and therefore
# Secret key names) are: certificate.pem, private-key.pem, chain.pem
- name: FlowerCore__Distribution__Signing__EditionCerts__kiosk-standard__CertPath
value: "/signing/kiosk-standard/chain.pem"
- name: FlowerCore__Distribution__Signing__EditionCerts__kiosk-standard__KeyPath
value: "/signing/kiosk-standard/private-key.pem"
- name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__CertPath
value: "/signing/aistation-field/chain.pem"
- name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__KeyPath
value: "/signing/aistation-field/private-key.pem"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# /healthz is exposed by the scaffold (StartupGateMiddleware-aware).
# Liveness uses tcpSocket as a cheap fallback in case a future
# middleware change accidentally gates /healthz behind auth
# (memory: feedback_k8s_probes_behind_auth_middleware).
startupProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: sqlite
mountPath: /data
subPath: distribution/data
- name: blobs
mountPath: /blobs
subPath: distribution/blobs
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
- name: kiosk-standard
mountPath: /signing/kiosk-standard
readOnly: true
- name: aistation-field
mountPath: /signing/aistation-field
readOnly: true
volumes:
# Synology NFS at /volume1/kubernetes — same export pattern as
# apps/guacamole/guacamole.yaml (recordings volume). Pinned by
# ACL to rke2-server. Never mount the subpath as nfs.path —
# always mount the export root and use volumeMount.subPath.
- name: sqlite
nfs:
server: 10.0.58.3
path: /volume1/kubernetes
- name: blobs
nfs:
server: 10.0.58.3
path: /volume1/kubernetes
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
- name: kiosk-standard
secret:
secretName: edition-kiosk-standard
defaultMode: 0400
- name: aistation-field
secret:
secretName: edition-aistation-field
defaultMode: 0400
---
apiVersion: v1
kind: Service
metadata:
name: fc-distribution
namespace: fc-distribution
labels:
app.kubernetes.io/name: fc-distribution
app.kubernetes.io/part-of: flowercore
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: fc-distribution
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-distribution-tls
namespace: fc-distribution
spec:
secretName: fc-distribution-tls-secret
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- dist.iamworkin.lan
duration: 2160h # 90d
renewBefore: 720h # 30d
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-distribution
namespace: fc-distribution
spec:
entryPoints:
- websecure
routes:
- match: Host(`dist.iamworkin.lan`)
kind: Rule
services:
- name: fc-distribution
port: 80
tls:
secretName: fc-distribution-tls-secret
---
# === dist.flowercore.io public surface (2026-04-24) =========================
#
# Shares the Deployment + Service + PVC with the internal IngressRoute above.
# The controller's NamedEntitlementResolverRouter picks between the internal
# (permissive) and public (strict) StaticTokenEntitlementResolver based on
# the X-FC-Distribution-Profile header — which the middleware below injects
# on every public-host request after stripping any caller-supplied value.
#
# Cert is the shared Cloudflare Origin Certificate for *.flowercore.io, literal
# bytes copied (matches gitea-public, matrix, telephony, mail, flowercore-landing
# pattern — not yet via OnePasswordItem operator).
---
apiVersion: v1
kind: Secret
metadata:
name: cf-origin-flowercore-io
namespace: fc-distribution
type: kubernetes.io/tls
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVvRENDQTRpZ0F3SUJBZ0lVSXN4c1NKV1VRL0tqZ09ldk81YnNuVi9rZVE4d0RRWUpLb1pJaHZjTkFRRUwKQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJrd0Z3WURWUVFLRXhCRGJHOTFaRVpzWVhKbExDQkpibU11TVRRdwpNZ1lEVlFRTEV5dERiRzkxWkVac1lYSmxJRTl5YVdkcGJpQlRVMHdnUTJWeWRHbG1hV05oZEdVZ1FYVjBhRzl5CmFYUjVNUll3RkFZRFZRUUhFdzFUWVc0Z1JuSmhibU5wYzJOdk1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGgKTUI0WERUSTJNRE14TURFMk16TXdNRm9YRFRReE1ETXdOakUyTXpNd01Gb3dZakVaTUJjR0ExVUVDaE1RUTJ4dgpkV1JHYkdGeVpTd2dTVzVqTGpFZE1Cc0dBMVVFQ3hNVVEyeHZkV1JHYkdGeVpTQlBjbWxuYVc0Z1EwRXhKakFrCkJnTlZCQU1USFVOc2IzVmtSbXhoY21VZ1QzSnBaMmx1SUVObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUcKOXcwQkFRRUZBQU9DQVE0QU1JSUJDZ0tDQVFFQXV0QmpkQ0xEdHdMQlZCU0Y1ZU1OMkt3ckIxTmZmRVhRMjlRRAo1aVR0dzJFcEZXNVJJSllkMjNrYUpCMU5jZXpHWlg4a0Q0cGEyWHpFZW1MVEtJNWw0MU11b3FoWjczNVE3U3RWCkVjRFFTT2ZYTkZQdFMwb0hqb0pRdGF2QjM0ZmJNR3l4Mmx0MU9HUzRNMGtLUWpBNWR6OTJQYjNyZ1RKR0JhOW4KeTZtVThncjRuUHRSdklxZ3NxdjRtMFA3dVU1YjE3NzU1Y2JLSDVoMzIxWHVjMDU4Tzl4M2JHQ0NuRUJXWDdqeApjRGhkUEs1Ri9XRjVBQnl5cFhIQ0ZxUUd4M1NVbmtCQ0ZQSmRabnMra3BHVUZWZGhud3B6NjBtNnlJSzQ0eVR4CjZqR3JOTFEyM1dOK2gwU1lCZU5vb2JBWThydkpiVlZEaGJqSVhBTWtFNGQzVll1TlhRSURBUUFCbzRJQklqQ0MKQVI0d0RnWURWUjBQQVFIL0JBUURBZ1dnTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjRApBVEFNQmdOVkhSTUJBZjhFQWpBQU1CMEdBMVVkRGdRV0JCUkt1NkJVUDZ0N2dpbFRPay9FdEdKQ3R6N3dTREFmCkJnTlZIU01FR0RBV2dCUWs2Rk5YWFh3MFFJZXA2NVRidXVFV2VQd3BwREJBQmdnckJnRUZCUWNCQVFRME1ESXcKTUFZSUt3WUJCUVVITUFHR0pHaDBkSEE2THk5dlkzTndMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5agpZVEFqQmdOVkhSRUVIREFhZ2d3cUxtbGhiWGR2Y21zdWFXNkNDbWxoYlhkdmNtc3VhVzR3T0FZRFZSMGZCREV3Ckx6QXRvQ3VnS1lZbmFIUjBjRG92TDJOeWJDNWpiRzkxWkdac1lYSmxMbU52YlM5dmNtbG5hVzVmWTJFdVkzSnMKTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFDSjMvTGNleE5pb0lWdUxoemhmbTZCeDV2SWk3T25CaHF1WUlDdwplNnArZ0prdE16ZFJQcDV0bk03dllBWmxMajVJOTByWDRuczhJc3dEbzJBN2wwYTRGZVJFclFmRklsZXQzbjIyCjUxVTZYVElCSks5c1FZT0FkU3pJUzV1OUNKSFpBUTF5WmxSd3BBR3RVWnhxL1dpcGFWUTRwNXhrcEJNMVlZSlAKNW1jQ09HcFErSnpORlpQc2daYUJncDBYL1BBZkNJRkkyZld5QWE2elBqRm0rdDVXUXIrZlBaT2VUS2VIbWVzVgo3UlZxUUdEb3Q0eTY1NklEdmdmU2ZLRnFIRW9XNDJVbDBxQ05hMS9keEJld3NIS1VWWE1ETkdiQlNVQjM4TG9YCm1OQ3hJQlVOUjR0TG1CQUxZT3hVMnZhSWRCd0xBc2YrcndnVnVjUGpCUTc2VWMwUQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQzYwR04wSXNPM0FzRlUKRklYbDR3M1lyQ3NIYTE5OFJkRGIxQVBtSk8zRFlTa1ZibEVnbGgzYmVSb2tIVTF4N01abGZ5UVBpbHJaZk1SNgpZdE1vam1YalV5NmlxRm52ZmxEdEsxVVJ3TkJJNTljMFUrMUxTZ2VPZ2xDMXE4SGZoOXN3YkxIYVczVTRaTGd6ClNRcENNRGwzUDNZOXZldUJNa1lGcjJmTHFaVHlDdmljKzFHOGlxQ3lxL2liUS91NVRsdlh2dm5seHNvZm1IZmIKVmU1elRudzczSGRzWUlLY1FGWmZ1UEZ3T0YwOHJrWDlZWGtBSExLbGNjSVdwQWJIZEpTZVFFSVU4bDFtZXo2UwprWlFWVjJHZkNuUHJTYnJJZ3JqakpQSHFNYXMwdERiZFkzNkhSSmdGNDJpaHNCanl1OGx0VlVPRnVNaGNBeVFUCmgzZFZpNDFkQWdNQkFBRUNnZ0VBTGlseXZkNmVTcEYvZUxtV2lhTVV4NUxwa2dhWHpITkxCQnNNZUpqcytLL0EKVVdlZ1crTkVUdmlLalZ5QlI5SzRocG1IYldDa2lPUDBBQUwrQnlKQ3lvekNOQmJTSEdRejlwc1R5dzZBV1ZlUwpuYjlVWGx1VmFQRktKTTRqbXNydERuYjVic25WT2lGblErTDdTalkwNlFMUlFybjBvUWp0ZFJldUdBMFlQVU90CkhSYzNsMFg2ZHJqdkJYY2prWTQwWm9ZYkRrelJnU1JWbWVOUGFIbjZPR0NtYUVUMXVyK01qYVZ2ME9lbEdIWncKVzljSEIxaHNxRzUvMWU3V0RQN0l0cjkwTmg4ay81NVhiK3lQUnhsRFd5bWtZMzIvdFBtZzdESTRKV2tRRWt3cgpIZUtwODVTcE5ta1liRnVpVFppeU8zZDZ0aXZHNHhFZW8rSzFVVFU4c1FLQmdRRFRNSEU1RDFYVC9HbGR5VHNsCllrODRVL1N0NXUrK2RIUEt1Wmw2dVB0UGgxV1lrdnFRcmdrL05YanVud2xGN0Y3b2tWOGdPeWxreTYwYTZkcXIKeXZwN1ZJdXYzekVlc2h2NjNWMlpaVkMzcXZYSzFheit3Zmx3NitCZmVuRlY5S2NENHN0dTdwOFRPWmFGN01CUgo3YXZzaXVXbWtqdmM1TlVLRmVDRTY0SnZFUUtCZ1FEaWMrbWlNLzBodDN1ajhuOXgyMDFQZFNqbEpVaUc1NjNNCnRYZlBCdDJRT0NhaVluUFNFdTdXdm5pQWRFL2xrMm91cFRWam9LYmZPbDFyQjd6UzVhc2kxdVdDZDhlUy9UWGIKdU5iRmlNMDB4L3JxalMydCtQbTd4MVhrYTB4TFNSRDNmZ0tSQldSN3pscStkYWZ1WE1qelUxRnh5dTIycGphRgpIMEl3NEpCUmpRS0JnUUNOaWhMb0Rob1V5RCtKNXJzb00vb3FJMEtDWnB0WlJzendHbkg5cVFwdFk2Ti9iVXBYCk92emhpeUh3czAvUXVEbG5uejVrNktHMmR6Y2VLWXN2eGdzWUt6S3ZmV043VWgya2hVWWM3NlVvWTREMkh6MGgKUkxtNzc2cGg4enNRUTdiSHlQRlUrTUpPYlRNdnNOdTRUUlVEcEplRGl0QnFIRWVYeWMrKzVlUjJNUUtCZ0h2UgptVHVoWlpVYitEVEtrVGkyQ20yWnlBU1RBRGNUVW9xTjVyYUNNSDk4MUZNUnRmWjFkN1pmYXhBQmlQWWtSbmkrCnlKUnk4UXM1cEg2ek9tR3VSb2JFTGJYS3ZJcjRmSXhwWXJXYmVXaVV0L09yd2dCUUZHekNMNHEzeUgyWnMvYy8KSlRRYVdMa0JPY2pPR0VaUzRXVjZkeHZiTTJNZE9zNUxLeXdDZmFhNUFvR0FIQUE1eEN0dndOZE4xeExndkZ3RApPK2lyMDl1bXMxOFBzSVpmK1ZrWGtpcHF4MWNUT0hEanpPR01yWXV0M2FFeE00Zjd2ckFHRFMyY2pwZjM0T1JxCit4Y2gwWlNaQ2FDZmlnZG9OelNkcDFLcmo0cnFKdG5ZdS9CNDlDQlVoSDBNaCtSRWswQ0hHOVE4b3FOWFk0V0wKbVVOVTZMYUkwQWtvSzNVb2tWQVJEYXM9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
---
# Traefik middleware: strips any caller-supplied X-FC-Distribution-Profile,
# then sets an authoritative 'public' value so the controller routes to the
# strict entitlement resolver. The trust boundary is this middleware — the
# internal IngressRoute (dist.iamworkin.lan) does NOT attach it.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: dist-public-profile-header
namespace: fc-distribution
spec:
headers:
customRequestHeaders:
X-FC-Distribution-Profile: "public"
---
# Public IngressRoute: binds dist.flowercore.io (Cloudflare-proxied A record
# -> pfSense NAT -> Traefik VIP 10.0.56.200) to the same backend Service that
# serves dist.iamworkin.lan. Header-injection middleware ensures the
# controller uses the public (strict) entitlement resolver.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-distribution-public
namespace: fc-distribution
spec:
entryPoints:
- websecure
routes:
# Method allowlist: Host + (GET || HEAD). Anything else misses every
# route and Traefik returns 404 before reaching the pod — edge-level
# defense-in-depth over the controller's strict-mode entitlement check.
# Together these block admin ops (POST /blobs, POST /manifests*) from
# ever being processed on the public surface.
- match: Host(`dist.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
kind: Rule
middlewares:
- name: dist-public-profile-header
services:
- name: fc-distribution
port: 80
tls:
secretName: cf-origin-flowercore-io

View File

@@ -0,0 +1,9 @@
# ArgoCD's bluejay-infra ApplicationSet uses a directory generator and does
# not require kustomization.yaml (existing apps like fc-llm-bridge and
# guacamole have none). This file is included anyway as a single source of
# truth for the resource list and to make `kubectl kustomize` previews work
# from a working copy.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- fc-distribution.yaml

32
apps/fc-dms/fc-dms.yaml Normal file
View File

@@ -0,0 +1,32 @@
# FlowerCore DMS — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dms-web-tls
namespace: fc-dms
spec:
secretName: dms-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- dms.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: dms-web
namespace: fc-dms
spec:
entryPoints:
- websecure
routes:
- match: Host(`dms.iamworkin.lan`)
kind: Rule
services:
- name: dms-web
port: 80
tls:
secretName: dms-web-tls

View File

@@ -1,320 +1,335 @@
# FlowerCore Landing Page
# Blue Jay Lab branded landing page - PUBLIC facing
# ArgoCD managed - BlueJay Lab
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-system
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Landing page HTML (public-safe - no internal LAN references)
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-landing-html
namespace: fc-system
data:
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FlowerCore</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a1628 0%, #1a2744 50%, #0d1f3c 100%);
color: #e0e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.hero {
text-align: center;
padding: 3rem;
max-width: 800px;
}
.logo {
font-size: 5rem;
margin-bottom: 1.5rem;
filter: drop-shadow(0 0 20px rgba(74, 158, 255, 0.3));
}
h1 {
font-size: 3rem;
background: linear-gradient(135deg, #4a9eff, #7ab3ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1.3rem;
color: #7ab3ff;
font-weight: 300;
margin-bottom: 1rem;
}
.description {
font-size: 1rem;
color: #8aa8c4;
line-height: 1.6;
margin-bottom: 3rem;
max-width: 600px;
}
.services {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
width: 100%;
max-width: 700px;
padding: 0 1rem;
}
.service {
background: rgba(74, 158, 255, 0.08);
border: 1px solid rgba(74, 158, 255, 0.2);
border-radius: 8px;
padding: 1.2rem;
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.service:hover {
background: rgba(74, 158, 255, 0.15);
border-color: rgba(74, 158, 255, 0.5);
transform: translateY(-2px);
}
.service h3 { color: #4a9eff; font-size: 0.95rem; margin-bottom: 0.3rem; }
.service p { color: #8aa8c4; font-size: 0.8rem; }
.status-bar {
display: flex;
gap: 2rem;
margin-top: 2rem;
padding: 1rem 2rem;
background: rgba(74, 158, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(74, 158, 255, 0.1);
}
.status-item { text-align: center; }
.status-item .value { color: #4a9eff; font-size: 1.5rem; font-weight: 700; }
.status-item .label { color: #6a8ca4; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; }
.footer {
margin-top: 3rem;
color: #4a6580;
font-size: 0.8rem;
}
.footer a { color: #4a6580; text-decoration: none; }
.footer a:hover { color: #7ab3ff; }
</style>
</head>
<body>
<div class="hero">
<div class="logo">&#x1F33B;</div>
<h1>FlowerCore</h1>
<p class="subtitle">Blue Jay Lab</p>
<p class="description">
Multi-tenant service management platform built on .NET 10,
Kubernetes, and GitOps. Digital signage, telephony IVR,
MySQL/PHP hosting, and infrastructure automation.
</p>
</div>
<div class="services">
<a class="service" href="https://gitea.flowercore.io">
<h3>Source</h3>
<p>Gitea repositories</p>
</a>
<a class="service" href="https://webmail.flowercore.io">
<h3>Mail</h3>
<p>Webmail access</p>
</a>
<a class="service" href="https://element.flowercore.io">
<h3>Chat</h3>
<p>Matrix messaging</p>
</a>
<a class="service" href="https://github.com/FlowerCoreIO">
<h3>GitHub</h3>
<p>Open source</p>
</a>
</div>
<div class="status-bar">
<div class="status-item">
<div class="value">17</div>
<div class="label">Services</div>
</div>
<div class="status-item">
<div class="value">13</div>
<div class="label">VLANs</div>
</div>
<div class="status-item">
<div class="value">12k+</div>
<div class="label">Tests</div>
</div>
</div>
<p class="footer">
FlowerCore &middot; Bare-metal RKE2 &middot; ArgoCD managed
&middot; <a href="mailto:admin@flowercore.io">Contact</a>
</p>
</body>
</html>
---
# nginx configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-landing-nginx-conf
namespace: fc-system
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;
}
}
---
# Landing Page Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-landing
namespace: fc-system
labels:
app: fc-landing
spec:
replicas: 1
selector:
matchLabels:
app: fc-landing
template:
metadata:
labels:
app: fc-landing
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: 16Mi
cpu: 5m
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: fc-landing-nginx-conf
- name: html
configMap:
name: fc-landing-html
---
apiVersion: v1
kind: Service
metadata:
name: fc-landing
namespace: fc-system
spec:
selector:
app: fc-landing
ports:
- port: 80
targetPort: 80
name: http
---
# Internal IngressRoute (LAN access)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-landing
namespace: fc-system
spec:
entryPoints:
- websecure
routes:
- match: Host(`flowercore.iamworkin.lan`)
kind: Rule
services:
- name: fc-landing
port: 80
tls: {}
---
# Public IngressRoute (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-landing-public
namespace: fc-system
spec:
entryPoints:
- websecure
routes:
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`)
kind: Rule
services:
- name: fc-landing
port: 80
tls:
secretName: cf-origin-flowercore-io
---
# HTTP to HTTPS redirect for public domain
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-landing-public-http
namespace: fc-system
spec:
entryPoints:
- web
routes:
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`)
kind: Rule
services:
- name: fc-landing
port: 80
middlewares:
- name: redirect-https
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
namespace: fc-system
spec:
redirectScheme:
scheme: https
permanent: true
# FlowerCore Landing Page
# Blue Jay Lab branded landing page - PUBLIC facing
# ArgoCD managed - BlueJay Lab
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-system
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Landing page HTML (public-safe - no internal LAN references)
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-landing-html
namespace: fc-system
data:
index.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FlowerCore</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a1628 0%, #1a2744 50%, #0d1f3c 100%);
color: #e0e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.hero {
text-align: center;
padding: 3rem;
max-width: 800px;
}
.logo {
font-size: 5rem;
margin-bottom: 1.5rem;
filter: drop-shadow(0 0 20px rgba(74, 158, 255, 0.3));
}
h1 {
font-size: 3rem;
background: linear-gradient(135deg, #4a9eff, #7ab3ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1.3rem;
color: #7ab3ff;
font-weight: 300;
margin-bottom: 1rem;
}
.description {
font-size: 1rem;
color: #8aa8c4;
line-height: 1.6;
margin-bottom: 3rem;
max-width: 600px;
}
.services {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
width: 100%;
max-width: 700px;
padding: 0 1rem;
}
.service {
background: rgba(74, 158, 255, 0.08);
border: 1px solid rgba(74, 158, 255, 0.2);
border-radius: 8px;
padding: 1.2rem;
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.service:hover {
background: rgba(74, 158, 255, 0.15);
border-color: rgba(74, 158, 255, 0.5);
transform: translateY(-2px);
}
.service h3 { color: #4a9eff; font-size: 0.95rem; margin-bottom: 0.3rem; }
.service p { color: #8aa8c4; font-size: 0.8rem; }
.status-bar {
display: flex;
gap: 2rem;
margin-top: 2rem;
padding: 1rem 2rem;
background: rgba(74, 158, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(74, 158, 255, 0.1);
}
.status-item { text-align: center; }
.status-item .value { color: #4a9eff; font-size: 1.5rem; font-weight: 700; }
.status-item .label { color: #6a8ca4; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; }
.footer {
margin-top: 3rem;
color: #4a6580;
font-size: 0.8rem;
}
.footer a { color: #4a6580; text-decoration: none; }
.footer a:hover { color: #7ab3ff; }
</style>
</head>
<body>
<div class="hero">
<div class="logo">&#x1F33B;</div>
<h1>FlowerCore</h1>
<p class="subtitle">Blue Jay Lab</p>
<p class="description">
Multi-tenant service management platform built on .NET 10,
Kubernetes, and GitOps. Digital signage, telephony IVR,
MySQL/PHP hosting, and infrastructure automation.
</p>
</div>
<div class="services">
<a class="service" href="https://gitea.flowercore.io">
<h3>Source</h3>
<p>Gitea repositories</p>
</a>
<a class="service" href="https://webmail.flowercore.io">
<h3>Mail</h3>
<p>Webmail access</p>
</a>
<a class="service" href="https://element.flowercore.io">
<h3>Chat</h3>
<p>Matrix messaging</p>
</a>
<a class="service" href="https://github.com/FlowerCoreIO">
<h3>GitHub</h3>
<p>Open source</p>
</a>
</div>
<div class="status-bar">
<div class="status-item">
<div class="value">17</div>
<div class="label">Services</div>
</div>
<div class="status-item">
<div class="value">13</div>
<div class="label">VLANs</div>
</div>
<div class="status-item">
<div class="value">12k+</div>
<div class="label">Tests</div>
</div>
</div>
<p class="footer">
FlowerCore &middot; Bare-metal RKE2 &middot; ArgoCD managed
&middot; <a href="mailto:admin@flowercore.io">Contact</a>
</p>
</body>
</html>
---
# nginx configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-landing-nginx-conf
namespace: fc-system
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;
}
}
---
# Landing Page Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-landing
namespace: fc-system
labels:
app: fc-landing
spec:
replicas: 1
selector:
matchLabels:
app: fc-landing
template:
metadata:
labels:
app: fc-landing
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: 16Mi
cpu: 5m
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: fc-landing-nginx-conf
- name: html
configMap:
name: fc-landing-html
---
apiVersion: v1
kind: Service
metadata:
name: fc-landing
namespace: fc-system
spec:
selector:
app: fc-landing
ports:
- port: 80
targetPort: 80
name: http
---
# TLS Certificate for internal LAN access
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-landing-tls
namespace: fc-system
spec:
secretName: fc-landing-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- flowercore.iamworkin.lan
---
# Internal IngressRoute (LAN access)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-landing
namespace: fc-system
spec:
entryPoints:
- websecure
routes:
- match: Host(`flowercore.iamworkin.lan`)
kind: Rule
services:
- name: fc-landing
port: 80
tls:
secretName: fc-landing-tls
---
# Public IngressRoute (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-landing-public
namespace: fc-system
spec:
entryPoints:
- websecure
routes:
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`)
kind: Rule
services:
- name: fc-landing
port: 80
tls:
secretName: cf-origin-flowercore-io
---
# HTTP to HTTPS redirect for public domain
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-landing-public-http
namespace: fc-system
spec:
entryPoints:
- web
routes:
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`)
kind: Rule
services:
- name: fc-landing
port: 80
middlewares:
- name: redirect-https
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
namespace: fc-system
spec:
redirectScheme:
scheme: https
permanent: true

View File

@@ -0,0 +1,174 @@
# fc-llm-bridge — staged deployment (ADR-088)
**Status:** manifests staged, **NOT YET APPLIED**. Do not `git push` or sync
ArgoCD until the two pre-requisites below are done, in order.
Design: [`../../../FlowerCore.Notes/docs/ai-agents/agent-zero-anthropic-bridge.md`](../../../FlowerCore.Notes/docs/ai-agents/agent-zero-anthropic-bridge.md)
ADR: ADR-088 in [`../../../FlowerCore.Notes/ARCHITECTURE.md`](../../../FlowerCore.Notes/ARCHITECTURE.md)
## Deployment order (do NOT skip / reorder)
### 1. FlowerCore.DNS preflight — REQUIRED FIRST
`fc-llm-bridge.iamworkin.lan` must keep resolving to `10.0.56.200` through
FlowerCore.DNS before this manifest is applied.
step-ca (the ACME CA on noc1) uses pfSense Unbound (10.0.56.1), **not**
cluster CoreDNS. If you apply this manifest before adding the DNS override,
cert-manager's HTTP-01 challenge silently fails for ~2h (exponential backoff)
until someone manually runs `kubectl -n fc-llm-bridge delete order <order>`
to bust the cache. See memory `feedback_pfsense_dns_required_for_acme.md`.
Verify the record through the public preflight API:
```bash
curl -sk "https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight?hostname=fc-llm-bridge.iamworkin.lan"
# Expect: "resolvable": true
```
Verify:
```bash
python scripts/check-pfsense-dns.py
# Historical filename retained; implementation now calls FlowerCore.DNS
# resolve-preflight instead of raw resolver lookups.
```
If the record is missing, recreate it through FlowerCore.DNS before pushing:
```bash
curl -sk https://dns.iamworkin.lan/api/v1/servers
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
-H "Content-Type: application/json" \
-d '{"name":"fc-llm-bridge","type":"A","data":"10.0.56.200","ttl":300}'
```
### 2. Create the `FC LLM Bridge API Keys` 1Password item
The `Claude API Key` item in vault `IAmWorkin` already exists (id
`e5tth3y5mp3lhdavg35pxadzca`, see `docs/ai-agents/anthropic-integration.md`).
The new item for per-consumer bridge API keys does NOT yet exist. Create it
before the first apply of this manifest — the Deployment marks the individual
key env vars `optional: true` so missing keys will not crash the pod, but the
bridge will reject every request with 401 until at least one key is populated.
| Field | Item position | Type | Purpose |
|-------|---------------|------|---------|
| `credential` | Top section | Password (random, 48 char) | Unused placeholder required by the 1Password schema for single-field items. Can be anything — this file is never read by K8s. |
| `agent-zero-ws` | "API Keys" section | Password (random, 48 char) | API key for the BLUEJAY-WS Agent Zero instance. |
| `agent-zero-k8s` | "API Keys" section | Password (random, 48 char) | API key for the K8s-hosted `agent-zero` Deployment. |
| `spare-1` | "API Keys" section | Password (random, 48 char) | Reserve for future Agent Zero forks / smoke-test scripts. |
| `spare-2` | "API Keys" section | Password (random, 48 char) | Reserve. |
Steps via the CLI (run from a machine with `op` signed in):
```bash
op item create \
--category="API Credential" \
--title="FC LLM Bridge API Keys" \
--vault="IAmWorkin" \
"API Keys.agent-zero-ws[password]=$(openssl rand -hex 24)" \
"API Keys.agent-zero-k8s[password]=$(openssl rand -hex 24)" \
"API Keys.spare-1[password]=$(openssl rand -hex 24)" \
"API Keys.spare-2[password]=$(openssl rand -hex 24)"
```
OR via the 1Password GUI — create a new item titled exactly `FC LLM Bridge API
Keys` in the `IAmWorkin` vault, add an `API Keys` section, add four password
fields named `agent-zero-ws`, `agent-zero-k8s`, `spare-1`, `spare-2` with
`openssl rand -hex 24` values.
**Mapping to K8s:** The 1Password Connect operator syncs each field to a
Secret key of the same name. The Deployment's env vars
(`FlowerCore__LlmBridge__ApiKeys__agent-zero-ws` etc) reference those Secret
keys. In `FlowerCore.Shared.Api.Authentication.ApiKeyAuthMiddleware`, the key
name (e.g. `agent-zero-k8s`) becomes the `fc.app` claim on the
`ClaimsPrincipal`, which is what `IBudgetLedger` uses to scope spend per
consumer.
### 3. Build + import the image to every RKE2 node
```bash
# From BLUEJAY-WS, in D:\git\FlowerCore\FlowerCore.LlmBridge
TAG="v$(date +%Y%m%d%H%M%S)"
dotnet.exe publish -c Release -o deploy/app \
src/FlowerCore.LlmBridge.Web/FlowerCore.LlmBridge.Web.csproj
podman build -t localhost/fc-llm-bridge:$TAG -f deploy/Dockerfile.deploy deploy
podman save localhost/fc-llm-bridge:$TAG -o /tmp/fc-llm-bridge.tar
# SCP to each node and ctr import
for NODE in rke2-server rke2-agent1 rke2-agent2; do
scp /tmp/fc-llm-bridge.tar $NODE:/tmp/
ssh $NODE "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-llm-bridge.tar"
done
```
### 4. Bump the image tag in the manifest
Edit `fc-llm-bridge.yaml`, replace `localhost/fc-llm-bridge:v00000000000000`
with the tag from step 3.
### 5. Commit + push
```bash
cd D:/git/FlowerCore/bluejay-infra
# re-run the DNS gate
python scripts/check-pfsense-dns.py
git add apps/fc-llm-bridge/
git commit -m "feat(fc-llm-bridge): deploy ADR-088 Agent Zero bridge"
git push
```
ArgoCD picks up within ~3 minutes and creates `infra-fc-llm-bridge`.
### 6. Verify
```bash
# From noc1
fcadmin_ssh noc1 '
kubectl -n argocd get application infra-fc-llm-bridge
kubectl -n fc-llm-bridge get certificate,pod
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://fc-llm-bridge.iamworkin.lan/healthz
'
```
Expect: Certificate `Ready: True` within ~60s, `/healthz` HTTP 200.
### 7. Flip Agent Zero to the bridge
After the bridge passes a real chat smoke test, update the Agent Zero
ConfigMap (`apps/agent-zero/agent-zero.yaml`) to route through the bridge:
- `A0_SET_chat_model_api_base` / `config.json > chat_model.api_base`
-> `https://fc-llm-bridge.iamworkin.lan/v1`
- Add an `A0_SET_chat_model_api_key` env var wired to a K8s Secret sourced
from `FC LLM Bridge API Keys` field `agent-zero-k8s`.
- Set `chat_model.name` to `fc:balanced` (or a concrete model) — the bridge
accepts both tier aliases and concrete model names.
Do the same for BLUEJAY-WS Agent Zero (`agent-zero-ws` key), or keep the
workstation on direct Ollama and only route Anthropic calls through the
bridge (the design doc describes this split as the preferred approach).
## Current state at staging time (2026-04-23)
- `fc-llm-bridge.iamworkin.lan` — public FlowerCore.DNS preflight is now
green and resolves to `10.0.56.200`; keep `python scripts/check-pfsense-dns.py`
green before push.
- `FC LLM Bridge API Keys` — NOT created in 1Password (user action).
- `Claude API Key` — already exists in `IAmWorkin` vault
(`e5tth3y5mp3lhdavg35pxadzca`), also consumed by AiStation and Chat.Web.
- `localhost/fc-llm-bridge:v*` image — not yet built; `FlowerCore.LlmBridge`
repo has local commit `6d285b5` only, no remote.
- ArgoCD `infra-fc-llm-bridge` Application — will be auto-created by the
`bluejay-infra` ApplicationSet once the directory is on `main`.
## Why tcpSocket probes (not `/healthz`)
The bridge runs `ApiKeyAuthMiddleware`. `/healthz` and `/health` are exempt
via `FlowerCore:LlmBridge:AuthExemptPaths`, so an HTTP probe would work
today. But a future change to the middleware registration order could
silently turn kubelet probes into 401/404, which crashes pods on every
deploy. `tcpSocket` keeps probes robust against that regression. Memory:
`feedback_k8s_probes_behind_auth_middleware.md`.

View File

@@ -0,0 +1,280 @@
# FlowerCore.LlmBridge — OpenAI-compatible bridge for Agent Zero.
# Routes through FlowerCore.Shared.Chat (ILlmProviderClient) with budget
# enforcement, response caching, and tier-based model routing. Lets Agent
# Zero (Python) reach Anthropic and Ollama providers without re-implementing
# the C# budget/cache/router primitives.
#
# Design: FlowerCore.Notes/docs/ai-agents/agent-zero-anthropic-bridge.md
# ADR: FlowerCore.Notes/ARCHITECTURE.md (ADR-088)
#
# Deployment order (see bluejay-infra/README.md):
# 1. pfSense DNS override for fc-llm-bridge.iamworkin.lan -> 10.0.56.200
# (REQUIRED before this is applied — cert-manager HTTP-01 will silently
# fail for ~2h backoff otherwise). Run scripts/pfsense-add-dns-overrides.py.
# 2. 1Password items `Claude API Key` (already exists) and
# `FC LLM Bridge API Keys` (create when first non-dev environment comes up).
# 3. Build + import image: localhost/fc-llm-bridge:v<YYYYMMDD><HHMM>
# Import to rke2-server, rke2-agent1, rke2-agent2 via ctr images import.
# 4. Bump the image tag below and git push; ArgoCD ApplicationSet picks up.
# 5. Flip Agent Zero chat.openai.base_url to https://fc-llm-bridge.iamworkin.lan/v1
# and api_key to the op://IAmWorkin/FC LLM Bridge API Keys/agent-zero-k8s value.
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-llm-bridge
labels:
app.kubernetes.io/part-of: flowercore
---
# Claude (Anthropic) API key — shared across FC services.
# Existing 1Password item. `credential` field -> Secret `anthropic-api-key`.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: anthropic-api-key
namespace: fc-llm-bridge
spec:
itemPath: "vaults/IAmWorkin/items/Claude API Key"
---
# Per-consumer API keys for the bridge itself.
# NEW 1Password item — see apps/fc-llm-bridge/README.md for the field layout
# to create before first apply. Fields become Secret keys of the same name:
# agent-zero-ws, agent-zero-k8s, spare-1, spare-2
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: fc-llm-bridge-api-keys
namespace: fc-llm-bridge
spec:
itemPath: "vaults/IAmWorkin/items/FC LLM Bridge API Keys"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fc-llm-bridge-data
namespace: fc-llm-bridge
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-llm-bridge
namespace: fc-llm-bridge
labels:
app.kubernetes.io/name: fc-llm-bridge
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: fc-llm-bridge
template:
metadata:
labels:
app.kubernetes.io/name: fc-llm-bridge
app.kubernetes.io/part-of: flowercore
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
securityContext:
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
# Placeholder tag — bump to the image you built + imported to every
# RKE2 node before applying. Build with:
# dotnet.exe publish -c Release -o deploy/app \
# src/FlowerCore.LlmBridge.Web/FlowerCore.LlmBridge.Web.csproj
# podman build -t localhost/fc-llm-bridge:v<tag> -f deploy/Dockerfile.deploy deploy
image: localhost/fc-llm-bridge:v202604300022
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
# SQLite (budget ledger + response cache + data-protection keys)
- name: FlowerCore__LlmBridge__SqliteConnectionString
value: "Data Source=/data/llm-bridge.db"
- name: FlowerCore__LlmBridge__DefaultTenantId
value: "default"
- name: FlowerCore__LlmBridge__DefaultAppName
value: "agent-zero"
- name: FlowerCore__LlmBridge__UtilModel
value: "qwen2.5:1.5b"
- name: FlowerCore__LlmBridge__EmbedModel
value: "nomic-embed-text"
# Per-consumer API keys — from OnePasswordItem fc-llm-bridge-api-keys.
# Each field becomes a Secret key of the same name. The key-name
# lands in the auth principal's `fc.app` claim for ledger scoping.
- name: FlowerCore__LlmBridge__ApiKeys__agent-zero-ws
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: agent-zero-ws
optional: true
- name: FlowerCore__LlmBridge__ApiKeys__agent-zero-k8s
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: agent-zero-k8s
optional: true
- name: FlowerCore__LlmBridge__ApiKeys__spare-1
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: spare-1
optional: true
- name: FlowerCore__LlmBridge__ApiKeys__spare-2
valueFrom:
secretKeyRef:
name: fc-llm-bridge-api-keys
key: spare-2
optional: true
# Shared.Chat — Ollama (edge1 Pi 5 + AI HAT+, matches bridge default)
- name: FlowerCore__Chat__OllamaBaseUrl
value: "http://10.0.57.17:11434"
- name: FlowerCore__Chat__HttpTimeout
value: "00:05:00"
# Shared.Chat — Anthropic
- name: FlowerCore__Chat__Anthropic__Enabled
value: "true"
- name: FlowerCore__Chat__Anthropic__ApiKey
valueFrom:
secretKeyRef:
name: anthropic-api-key
key: password
- name: FlowerCore__Chat__Anthropic__OrganizationId
valueFrom:
secretKeyRef:
name: anthropic-api-key
key: organization_id
optional: true
- name: FlowerCore__Chat__Anthropic__BaseUrl
value: "https://api.anthropic.com"
- name: FlowerCore__Chat__Anthropic__DefaultModel
value: "claude-sonnet-4-6"
- name: FlowerCore__Chat__Anthropic__AnthropicVersion
value: "2023-06-01"
- name: FlowerCore__Chat__Anthropic__Timeout
value: "00:05:00"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 768Mi
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
- name: app-data
mountPath: /app/data
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
# tcpSocket probes: the app runs ApiKeyAuthMiddleware. /healthz is
# registered as anonymous via AuthExemptPaths but tcpSocket avoids any
# future accidental middleware ordering regression
# (memory: feedback_k8s_probes_behind_auth_middleware).
readinessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 15
periodSeconds: 30
# Lower ndots so external FQDNs like api.anthropic.com are tried BEFORE
# the ndots:5 default expands them through the cluster search path, which
# includes iamworkin.lan. CoreDNS has a `template IN A iamworkin.lan`
# wildcard that answers `api.anthropic.com.iamworkin.lan` with the
# Traefik VIP, which then serves a TRAEFIK-DEFAULT-CERT TLS cert and
# breaks egress to the real Anthropic API (memory:
# feedback_coredns_ndots_template_collision, generalized to external DNS).
dnsConfig:
options:
- name: ndots
value: "2"
volumes:
- name: data
persistentVolumeClaim:
claimName: fc-llm-bridge-data
- name: tmp
emptyDir: {}
# The Dockerfile `WORKDIR /app` pairs with the default
# SqliteConnectionString "Data Source=data/llm-bridge.db" (relative).
# The env var above overrides to /data, so /app/data can be emptyDir.
- name: app-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: fc-llm-bridge
namespace: fc-llm-bridge
spec:
selector:
app.kubernetes.io/name: fc-llm-bridge
ports:
- port: 8080
targetPort: 8080
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-llm-bridge-cert
namespace: fc-llm-bridge
spec:
secretName: fc-llm-bridge-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- fc-llm-bridge.iamworkin.lan
duration: 720h
renewBefore: 240h
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-llm-bridge
namespace: fc-llm-bridge
spec:
entryPoints:
- websecure
routes:
- match: Host(`fc-llm-bridge.iamworkin.lan`)
kind: Rule
services:
- name: fc-llm-bridge
port: 8080
tls:
secretName: fc-llm-bridge-tls

View File

@@ -0,0 +1,32 @@
# FlowerCore MenuBoard — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: menuboard-web-tls
namespace: fc-menuboard
spec:
secretName: menuboard-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- menuboard.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: menuboard-web
namespace: fc-menuboard
spec:
entryPoints:
- websecure
routes:
- match: Host(`menuboard.iamworkin.lan`)
kind: Rule
services:
- name: menuboard-web
port: 80
tls:
secretName: menuboard-web-tls

View File

@@ -0,0 +1,145 @@
# FlowerCore MessageBoard — Message board service
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-messageboard
labels:
app.kubernetes.io/part-of: bluejay-infra
---
apiVersion: v1
kind: ConfigMap
metadata:
name: messageboard-web-config
namespace: fc-messageboard
data:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
Security__AllowedOrigins__0: https://messageboard.iamworkin.lan
FlowerCore__Database__ConnectionStrings__Sqlite: Data Source=/data/messageboard.db
OTEL_SERVICE_NAME: FlowerCore.MessageBoard
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector.monitoring.svc.cluster.local:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: messageboard-web
namespace: fc-messageboard
labels:
app: messageboard-web
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: messageboard-web
template:
metadata:
labels:
app: messageboard-web
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics/prometheus"
spec:
containers:
- name: messageboard-web
image: localhost/fc-messageboard-web:latest
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
envFrom:
- configMapRef:
name: messageboard-web-config
- secretRef:
name: messageboard-web-secrets
optional: true
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
volumes:
- name: data
persistentVolumeClaim:
claimName: messageboard-web-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: messageboard-web-data
namespace: fc-messageboard
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: messageboard-web
namespace: fc-messageboard
spec:
selector:
app: messageboard-web
ports:
- port: 80
targetPort: 8080
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: messageboard-web-tls
namespace: fc-messageboard
spec:
secretName: messageboard-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- messageboard.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: messageboard-web
namespace: fc-messageboard
spec:
entryPoints:
- websecure
routes:
- match: Host(`messageboard.iamworkin.lan`)
kind: Rule
services:
- name: messageboard-web
port: 80
tls:
secretName: messageboard-web-tls

View File

@@ -0,0 +1,32 @@
# FlowerCore MySQL Manager — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mysql-web-tls
namespace: fc-mysql
spec:
secretName: mysql-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- mysql.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: mysql-web
namespace: fc-mysql
spec:
entryPoints:
- websecure
routes:
- match: Host(`mysql.iamworkin.lan`)
kind: Rule
services:
- name: mysql-web
port: 5300
tls:
secretName: mysql-web-tls

32
apps/fc-php/fc-php.yaml Normal file
View File

@@ -0,0 +1,32 @@
# FlowerCore PHP Manager — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: php-web-tls
namespace: fc-php
spec:
secretName: php-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- php.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: php-web
namespace: fc-php
spec:
entryPoints:
- websecure
routes:
- match: Host(`php.iamworkin.lan`)
kind: Rule
services:
- name: php-web
port: 5400
tls:
secretName: php-web-tls

View File

@@ -0,0 +1,32 @@
# FlowerCore Presentations — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: presentations-web-tls
namespace: fc-presentations
spec:
secretName: presentations-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- presentations.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: presentations-web
namespace: fc-presentations
spec:
entryPoints:
- websecure
routes:
- match: Host(`presentations.iamworkin.lan`)
kind: Rule
services:
- name: presentations-web
port: 80
tls:
secretName: presentations-web-tls

View File

@@ -0,0 +1,32 @@
# FlowerCore Scoreboard — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: scoreboard-web-tls
namespace: fc-scoreboard
spec:
secretName: scoreboard-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- scoreboard.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: scoreboard-web
namespace: fc-scoreboard
spec:
entryPoints:
- websecure
routes:
- match: Host(`scoreboard.iamworkin.lan`)
kind: Rule
services:
- name: scoreboard-web
port: 80
tls:
secretName: scoreboard-web-tls

View File

@@ -0,0 +1,39 @@
# FlowerCore SegmentDisplay — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-segmentdisplay
labels:
app.kubernetes.io/part-of: flowercore
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: segmentdisplay-web-tls
namespace: fc-segmentdisplay
spec:
secretName: segmentdisplay-web-tls
issuerRef:
name: step-ca-dns01
kind: ClusterIssuer
dnsNames:
- segmentdisplay.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: segmentdisplay-web
namespace: fc-segmentdisplay
spec:
entryPoints:
- websecure
routes:
- match: Host(`segmentdisplay.iamworkin.lan`)
kind: Rule
services:
- name: segmentdisplay-web
port: 80
tls:
secretName: segmentdisplay-web-tls

View File

@@ -0,0 +1,48 @@
# FlowerCore Digital Signage — TLS + Ingress
# Deployment and Service managed by deploy script (not ArgoCD)
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: signage-web-tls
namespace: fc-signage
spec:
secretName: signage-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- signage.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: signage-web
namespace: fc-signage
spec:
entryPoints:
- websecure
routes:
- match: Host(`signage.iamworkin.lan`)
kind: Rule
services:
- name: signage-web
port: 5190
tls:
secretName: signage-web-tls
---
# HTTP route for signage players that may not use TLS
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: signage-web-http
namespace: fc-signage
spec:
entryPoints:
- web
routes:
- match: Host(`signage.iamworkin.lan`)
kind: Rule
services:
- name: signage-web
port: 5190

View File

@@ -0,0 +1,145 @@
# FlowerCore SignalControl — Signal sequencing and relay coordination
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-signalcontrol
labels:
app.kubernetes.io/part-of: bluejay-infra
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: signalcontrol-data
namespace: fc-signalcontrol
labels:
app.kubernetes.io/name: signalcontrol-web
app.kubernetes.io/part-of: flowercore
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: signalcontrol-web
namespace: fc-signalcontrol
labels:
app.kubernetes.io/name: signalcontrol-web
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: signalcontrol-web
template:
metadata:
labels:
app.kubernetes.io/name: signalcontrol-web
app.kubernetes.io/part-of: flowercore
spec:
containers:
- name: signalcontrol-web
image: localhost/fc-signalcontrol-web:latest
imagePullPolicy: Never
ports:
- containerPort: 5000
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: Production
- name: ASPNETCORE_URLS
value: "http://+:5000"
- name: ConnectionStrings__Default
value: Data Source=/data/signalcontrol.db
- name: Logging__LogLevel__Default
value: Information
- name: Auth__ApiKey
valueFrom:
secretKeyRef:
name: signalcontrol-auth
key: Auth__ApiKey
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 6
timeoutSeconds: 5
securityContext:
fsGroup: 4200
fsGroupChangePolicy: OnRootMismatch
volumes:
- name: data
persistentVolumeClaim:
claimName: signalcontrol-data
---
apiVersion: v1
kind: Service
metadata:
name: signalcontrol-web
namespace: fc-signalcontrol
labels:
app.kubernetes.io/name: signalcontrol-web
app.kubernetes.io/part-of: flowercore
spec:
selector:
app.kubernetes.io/name: signalcontrol-web
ports:
- port: 80
targetPort: http
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: signalcontrol-web-tls
namespace: fc-signalcontrol
spec:
secretName: signalcontrol-web-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- signalcontrol.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: signalcontrol-web
namespace: fc-signalcontrol
spec:
entryPoints:
- websecure
routes:
- match: Host(`signalcontrol.iamworkin.lan`)
kind: Rule
services:
- name: signalcontrol-web
port: 80
tls:
secretName: signalcontrol-web-tls

View File

@@ -0,0 +1,35 @@
# FlowerCore biblical-tts — eSpeak-NG-backed TTS for Ancient Greek (grc) and
# Hebrew (he). Wraps the espeak-ng binary in a small FastAPI app exposing
# /tts (returns WAV) and /timings (returns word timings via espeak's
# --pho output). Same shape as fc-speech-align so AiStation can talk to
# both with one HTTP client pattern.
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
# espeak-ng has built-in support for grc (Ancient Greek) and he (Hebrew).
# libsndfile1 is for the wav post-processing step.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
espeak-ng \
libsndfile1 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py /app/
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1654 tts
USER 1654
EXPOSE 10402
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:10402/health',timeout=3); sys.exit(0)" || exit 1
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "10402", "--workers", "1"]

View File

@@ -0,0 +1,211 @@
"""FlowerCore biblical-tts — eSpeak-NG wrapper for Ancient Greek + Hebrew.
Endpoints:
* POST /tts — body: {"text": "...", "language": "grc|he|el", "voice": "...?", "rate": 175?, "pitch": 50?}
returns audio/wav. eSpeak-NG handles the language
internally; voice fields like "grc" or "grc+f3"
(female variant 3) work directly.
* POST /timings — same body shape but returns
{"text": "...", "words": [{"text", "startMs", "endMs"}],
"durationMs": ...}.
Uses espeak's --pho phoneme output mapped onto
whitespace-split words by accumulated phoneme duration.
Read-along clients pair this with /tts for synced
playback.
* GET /voices — language metadata so AiStation can populate the
voice catalog at startup.
* GET /health — fast readiness check.
Source-language pronunciations are reconstructed/scholarly approximations.
This wraps eSpeak-NG; Ancient Greek (grc) follows Erasmian-style mappings,
and Hebrew (he) is Modern Hebrew pronunciation but the consonant
skeleton matches biblical Hebrew so the read-along visual cue still
lands on the right word even when the vowel pronunciation diverges.
"""
from __future__ import annotations
import io
import logging
import re
import shlex
import subprocess
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
LOG = logging.getLogger("biblical_tts")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
app = FastAPI(title="FlowerCore biblical-tts", version="1.0.0")
# eSpeak-NG language codes we expose. Ancient Greek + Hebrew are the headline
# pair; we also surface Modern Greek (el) since it's a useful fallback when
# operators want a closer-to-Erasmian feel.
LANGUAGES = {
"grc": {"label": "Ancient Greek (Erasmian)", "rtl": False, "default_voice": "grc"},
"el": {"label": "Modern Greek", "rtl": False, "default_voice": "el"},
"he": {"label": "Hebrew (Modern)", "rtl": True, "default_voice": "he"},
}
class TtsRequest(BaseModel):
text: str
language: str = "grc"
voice: Optional[str] = None
rate: int = 175 # words per minute, eSpeak default 175
pitch: int = 50 # 0-99
volume: int = 100 # 0-200
def _resolve_voice(req: TtsRequest) -> str:
if req.voice:
return req.voice.strip()
lang = req.language.lower()
return LANGUAGES.get(lang, {}).get("default_voice", lang)
def _run_espeak(args: list[str], stdin_text: bytes) -> bytes:
cmd = ["espeak-ng"] + args
LOG.info("espeak-ng %s", shlex.join(args))
try:
proc = subprocess.run(
cmd,
input=stdin_text,
capture_output=True,
timeout=60,
check=False,
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="espeak-ng timed out")
if proc.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"espeak-ng exit {proc.returncode}: {proc.stderr.decode('utf-8', errors='replace')[:512]}",
)
return proc.stdout
@app.get("/health")
def health():
return {"status": "ok", "languages": list(LANGUAGES.keys())}
@app.get("/voices")
def voices():
return {
"voices": [
{
"name": code,
"displayName": meta["label"],
"language": code,
"isRightToLeft": meta["rtl"],
"engine": "espeak-ng",
}
for code, meta in LANGUAGES.items()
]
}
@app.post("/tts")
def tts(req: TtsRequest) -> Response:
if not req.text.strip():
raise HTTPException(status_code=400, detail="text is required")
voice = _resolve_voice(req)
args = [
"--stdout",
"-v", voice,
"-s", str(max(80, min(450, req.rate))),
"-p", str(max(0, min(99, req.pitch))),
"-a", str(max(0, min(200, req.volume))),
]
wav = _run_espeak(args, req.text.encode("utf-8"))
if not wav:
raise HTTPException(status_code=500, detail="espeak-ng returned empty stdout")
return Response(content=wav, media_type="audio/wav")
# --------------------------------------------------------------------------
# /timings — synth + word-level timing from espeak's phoneme/word stream.
# --------------------------------------------------------------------------
#
# espeak-ng's --pho flag emits a phoneme stream:
#
# _ 5 phon...
# _ 56 phon...
# _ 67 phon...
#
# That alone doesn't give word boundaries. Easiest reliable path: run
# espeak-ng with --pho once to get the total acoustic length (sum of
# phoneme durations), then distribute that length across the input
# text's whitespace-split words proportional to their character count
# (eSpeak's actual per-word timing isn't easily extractable from CLI).
# That's accurate enough to drive read-along highlighting without
# wiring a deeper espeak-ng integration.
#
# When the operator pairs this with the /tts WAV at the same time, the
# returned word timings line up with playback to within ~30-80ms which
# is close enough for chip-level highlighting.
PHONEME_DURATION_RE = re.compile(r"^\s*\S+\s+(\d+)\s+", re.MULTILINE)
def _estimate_total_ms(req: TtsRequest, voice: str) -> int:
args = ["--pho", "--quiet", "-v", voice, "-s", str(req.rate)]
out = _run_espeak(args, req.text.encode("utf-8"))
text = out.decode("utf-8", errors="replace")
total = 0
for match in PHONEME_DURATION_RE.finditer(text):
try:
total += int(match.group(1))
except ValueError:
continue
if total == 0:
# Fallback: rough heuristic at the configured speech rate (words/minute).
words = max(1, len(req.text.split()))
total = int(words / max(60, req.rate) * 60_000)
return total
@app.post("/timings")
def timings(req: TtsRequest):
if not req.text.strip():
raise HTTPException(status_code=400, detail="text is required")
voice = _resolve_voice(req)
total_ms = _estimate_total_ms(req, voice)
# Distribute total_ms across whitespace-split words proportional to
# character count. Punctuation-only tokens are folded into the previous
# word so a Greek verse ending with " ." doesn't claim a chunk of time.
words = req.text.split()
if not words:
return {"text": req.text, "words": [], "durationMs": total_ms}
char_total = sum(max(1, len(w)) for w in words)
cursor = 0
out_words: list[dict] = []
for word in words:
weight = max(1, len(word))
share = int(round(total_ms * weight / char_total))
start = cursor
end = start + share
out_words.append({"text": word, "startMs": start, "endMs": end})
cursor = end
# Snap the last word's end to the actual total so the read-along loop
# never overshoots.
if out_words:
out_words[-1]["endMs"] = total_ms
return JSONResponse(
{
"text": req.text,
"language": req.language,
"voice": voice,
"words": out_words,
"durationMs": total_ms,
}
)

View File

@@ -0,0 +1,2 @@
fastapi==0.115.6
uvicorn==0.34.0

View File

@@ -0,0 +1,730 @@
# FlowerCore TTS Reader — Text-to-speech book reader service
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-ttsreader
labels:
app.kubernetes.io/part-of: flowercore
---
# 1Password -> K8s Secret sync for TTS Reader API keys
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: ttsreader-secrets
namespace: fc-ttsreader
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore TTS Reader"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ttsreader-piper
namespace: fc-ttsreader
labels:
app.kubernetes.io/name: ttsreader-piper
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ttsreader-piper
template:
metadata:
labels:
app.kubernetes.io/name: ttsreader-piper
app.kubernetes.io/part-of: flowercore
spec:
initContainers:
- name: seed-voices
image: rhasspy/wyoming-piper:latest
command:
- python3
- -c
args:
- |
import shutil
import ssl
from pathlib import Path
from urllib.request import urlopen
ssl._create_default_https_context = ssl._create_unverified_context
files = {
"en_US-lessac-high.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx",
"en_US-lessac-high.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx.json",
"en_US-lessac-medium.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx",
"en_US-lessac-medium.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json",
"en_US-amy-medium.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx",
"en_US-amy-medium.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx.json",
"en_US-john-medium.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/john/medium/en_US-john-medium.onnx",
"en_US-john-medium.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/john/medium/en_US-john-medium.onnx.json",
"en_GB-cori-high.onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/cori/high/en_GB-cori-high.onnx",
"en_GB-cori-high.onnx.json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/cori/high/en_GB-cori-high.onnx.json",
}
target = Path("/data")
target.mkdir(parents=True, exist_ok=True)
for name, url in files.items():
path = target / name
if path.exists() and path.stat().st_size > 0:
print(f"cached {name}", flush=True)
continue
print(f"downloading {name}", flush=True)
with urlopen(url, timeout=180) as response, open(path, "wb") as download_file:
shutil.copyfileobj(response, download_file)
print(f"ready {name}", flush=True)
volumeMounts:
- name: data
mountPath: /data
containers:
- name: piper
image: rhasspy/wyoming-piper:latest
env:
- name: PYTHONHTTPSVERIFY
value: "0"
args:
- "--voice"
- "en_US-lessac-high"
- "--data-dir"
- "/data"
- "--download-dir"
- "/data"
ports:
- containerPort: 10200
name: wyoming
# Memory bumped after observed OOMKills during real chapter
# renders 2026-04-25. Piper's eSpeak phonemizer + onnx runtime
# spikes well past 1 Gi on long unpunctuated paragraphs from
# PDF / book imports. 3 Gi gives headroom plus the
# transcribe-audio-to-Quick-Read flow that hits Piper through
# the same model.
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 2000m
memory: 3Gi
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: ttsreader-piper-data
---
# fc-speech-align — cluster-native faster-whisper wrapper.
# Exposes POST /align (fc-align contract used by FlowerCore.Shared.Speech) AND
# POST /transcribe (audio-file-in feature). CPU model = base.en, int8 compute.
# Source: bluejay-infra/apps/fc-ttsreader/speech-align/ (Dockerfile + app.py).
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ttsreader-align-models
namespace: fc-ttsreader
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ttsreader-align
namespace: fc-ttsreader
labels:
app.kubernetes.io/name: ttsreader-align
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ttsreader-align
template:
metadata:
labels:
app.kubernetes.io/name: ttsreader-align
app.kubernetes.io/part-of: flowercore
spec:
# Bypass CoreDNS's *.iamworkin.lan template hijack on public hosts
# (huggingface.co model download at first boot would otherwise resolve
# to Traefik VIP via search expansion). Drops the iamworkin.lan suffix.
dnsPolicy: None
dnsConfig:
nameservers:
- 10.43.0.10
searches:
- fc-ttsreader.svc.cluster.local
- svc.cluster.local
- cluster.local
options:
- name: ndots
value: "2"
securityContext:
fsGroup: 1654
runAsNonRoot: true
runAsUser: 1654
containers:
- name: align
image: localhost/fc-speech-align:v3
imagePullPolicy: Never
ports:
- containerPort: 9200
name: http
env:
- name: WHISPER_MODEL
value: "Systran/faster-whisper-base.en"
- name: WHISPER_DEVICE
value: "cpu"
- name: WHISPER_COMPUTE_TYPE
value: "int8"
- name: WHISPER_CACHE_DIR
value: "/models"
- name: DEFAULT_LANGUAGE
value: "en"
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 2000m
memory: 2Gi
volumeMounts:
- name: models
mountPath: /models
readinessProbe:
httpGet:
path: /health
port: 9200
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 18
livenessProbe:
httpGet:
path: /health
port: 9200
initialDelaySeconds: 180
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: models
persistentVolumeClaim:
claimName: ttsreader-align-models
---
apiVersion: v1
kind: Service
metadata:
name: ttsreader-align
namespace: fc-ttsreader
spec:
selector:
app.kubernetes.io/name: ttsreader-align
ports:
- port: 9200
targetPort: 9200
name: http
---
# ttsreader-kokoro — Kokoro-82M TTS via the kokoro-fastapi container.
# Provides high-quality English voices alongside Piper for the TtsReader
# render pipeline AND for AiStation when it talks to the cluster TTS plane
# (instead of pointing back at BLUEJAY-WS:10401). Model + voices ship
# inside the container image, so no PVC is needed.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ttsreader-kokoro
namespace: fc-ttsreader
labels:
app.kubernetes.io/name: ttsreader-kokoro
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ttsreader-kokoro
template:
metadata:
labels:
app.kubernetes.io/name: ttsreader-kokoro
app.kubernetes.io/part-of: flowercore
spec:
# Same DNS bypass as ttsreader-align — without it, the *.iamworkin.lan
# CoreDNS template would hijack hexgrad/Kokoro-82M's HuggingFace-style
# repo lookups during model warmup.
dnsPolicy: None
dnsConfig:
nameservers:
- 10.43.0.10
searches:
- fc-ttsreader.svc.cluster.local
- svc.cluster.local
- cluster.local
options:
- name: ndots
value: "2"
containers:
- name: kokoro
image: ghcr.io/remsky/kokoro-fastapi-cpu:latest
ports:
- containerPort: 8880
name: http
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
cpu: 2000m
memory: 3Gi
readinessProbe:
httpGet:
path: /v1/audio/voices
port: 8880
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 18
# Sprint E Phase 1a (kokoro stability) — 4 restarts in 2d6h with
# exit 143 traced to liveness probe `context deadline exceeded` while
# kokoro was busy synthesizing. /v1/audio/voices shares the FastAPI
# worker pool with /v1/audio/speech, so a long synth can starve the
# probe out within the prior 5s × 3 = 15s window. Bump timeoutSeconds
# 5 → 15 and failureThreshold 3 → 5 → 75s grace before kubelet kills
# the pod. The TtsCircuitBreaker on the synthesizer side (Phase 1b)
# backs this up so the FC backend stops slamming kokoro during
# recovery.
livenessProbe:
httpGet:
path: /v1/audio/voices
port: 8880
initialDelaySeconds: 180
periodSeconds: 30
timeoutSeconds: 15
failureThreshold: 5
---
# fc-biblical-tts — eSpeak-NG-backed Ancient Greek + Hebrew TTS with
# word-level timing for read-along playback. Companion to ttsreader-kokoro
# (modern English) and ttsreader-piper (English narrator); operators pick
# whichever engine matches the source text. Source:
# bluejay-infra/apps/fc-ttsreader/biblical-tts/
apiVersion: apps/v1
kind: Deployment
metadata:
name: ttsreader-biblical
namespace: fc-ttsreader
labels:
app.kubernetes.io/name: ttsreader-biblical
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ttsreader-biblical
template:
metadata:
labels:
app.kubernetes.io/name: ttsreader-biblical
app.kubernetes.io/part-of: flowercore
spec:
securityContext:
fsGroup: 1654
runAsNonRoot: true
runAsUser: 1654
containers:
- name: biblical-tts
image: localhost/fc-biblical-tts:v1
imagePullPolicy: Never
ports:
- containerPort: 10402
name: http
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1000m
memory: 512Mi
readinessProbe:
httpGet:
path: /health
port: 10402
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /health
port: 10402
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: ttsreader-biblical
namespace: fc-ttsreader
spec:
selector:
app.kubernetes.io/name: ttsreader-biblical
ports:
- port: 10402
targetPort: 10402
name: http
---
# fc-modern-tts — Microsoft Edge Read Aloud bridge for Modern Hebrew
# (he-IL-AvriNeural et al) and Modern Greek (el-GR-NestorasNeural et al).
# Pairs with ttsreader-biblical: biblical engine handles unpointed
# Greek + Hebrew, modern engine handles narrative translations the
# operator reads alongside.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ttsreader-modern
namespace: fc-ttsreader
labels:
app.kubernetes.io/name: ttsreader-modern
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ttsreader-modern
template:
metadata:
labels:
app.kubernetes.io/name: ttsreader-modern
app.kubernetes.io/part-of: flowercore
spec:
# edge-tts needs egress to *.tts.speech.microsoft.com — bypass the
# iamworkin.lan template hijack so the lookup doesn't fall back to
# Traefik VIP via search expansion.
dnsPolicy: None
dnsConfig:
nameservers:
- 10.43.0.10
searches:
- fc-ttsreader.svc.cluster.local
- svc.cluster.local
- cluster.local
options:
- name: ndots
value: "2"
securityContext:
fsGroup: 1654
runAsNonRoot: true
runAsUser: 1654
containers:
- name: modern-tts
image: localhost/fc-modern-tts:v1
imagePullPolicy: Never
ports:
- containerPort: 10403
name: http
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1000m
memory: 512Mi
readinessProbe:
httpGet:
path: /health
port: 10403
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /health
port: 10403
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: v1
kind: Service
metadata:
name: ttsreader-modern
namespace: fc-ttsreader
spec:
selector:
app.kubernetes.io/name: ttsreader-modern
ports:
- port: 10403
targetPort: 10403
name: http
---
apiVersion: v1
kind: Service
metadata:
name: ttsreader-kokoro
namespace: fc-ttsreader
spec:
selector:
app.kubernetes.io/name: ttsreader-kokoro
ports:
- port: 8880
targetPort: 8880
name: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ttsreader-web
namespace: fc-ttsreader
labels:
app.kubernetes.io/name: ttsreader-web
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ttsreader-web
template:
metadata:
labels:
app.kubernetes.io/name: ttsreader-web
app.kubernetes.io/part-of: flowercore
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "5217"
prometheus.io/path: "/metrics"
spec:
securityContext:
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
image: localhost/fc-ttsreader-web:v202604291817
imagePullPolicy: Never
ports:
- containerPort: 5217
name: http
env:
- name: ASPNETCORE_URLS
value: "http://+:5217"
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: FlowerCore__Database__ConnectionStrings__Sqlite
value: "Data Source=/data/ttsreader.db"
- name: TtsReader__Audio__OutputRoot
value: "/data/audio"
- name: TtsReader__Audio__FfmpegPath
value: "/usr/bin/ffmpeg"
- name: TtsReader__Bible__CorpusRoot
value: "/data/corpus-cache/world-english-bible/eng/usx"
- name: TtsReader__Jobs__Root
value: "/data/jobs"
- name: TtsReader__Piper__Host
value: "ttsreader-piper.fc-ttsreader.svc.cluster.local."
- name: TtsReader__Piper__Port
value: "10200"
- name: TtsReader__Kokoro__Enabled
value: "true"
- name: TtsReader__Kokoro__BaseUrl
# Cluster-native ttsreader-kokoro Service — replaces the prior
# BLUEJAY-WS host pointer so the render pipeline doesn't need
# the workstation up. AiStation can still hit its local
# http://localhost:8880 instance.
value: "http://ttsreader-kokoro.fc-ttsreader.svc.cluster.local.:8880"
- name: TtsReader__Kokoro__TimeoutSeconds
value: "120"
- name: Speech__Alignment__Enabled
# Cluster-native faster-whisper (Lane F, 2026-04-25). The
# ttsreader-align deployment in this manifest wraps
# SYSTRAN/faster-whisper with a /align endpoint matching the
# FlowerCore.Shared.Speech master contract.
value: "true"
- name: Speech__Alignment__BaseUrl
value: "http://ttsreader-align.fc-ttsreader.svc.cluster.local.:9200"
- name: Speech__Alignment__TimeoutSeconds
value: "120"
# Cluster-native transcription endpoint shares the same pod
# (POST /transcribe). Lane G consumes this from the
# FlowerCore.TtsReader.Web AudioImport feature.
- name: TtsReader__Transcription__Enabled
value: "true"
- name: TtsReader__Transcription__BaseUrl
value: "http://ttsreader-align.fc-ttsreader.svc.cluster.local.:9200"
- name: TtsReader__Transcription__TimeoutSeconds
value: "300"
- name: TtsReader__Ollama__BaseUrl
value: "http://10.0.57.17:11434"
- name: TtsReader__Ollama__DefaultModel
value: "gemma3:4b"
- name: TtsReader__Ollama__TimeoutSeconds
value: "45"
- name: TtsReader__Runtime__LogsRoot
value: "/data/logs"
- name: TtsReader__Runtime__SmokeStatePath
value: "/data/ops/smoke-status.json"
# Sprint E Day 8 voice-preview disk cache — writes WAVs under
# this directory. Default "data/voice-previews" resolves to
# the read-only $HOME path under runAsNonRoot=true. Pin to
# the writable PVC mount.
- name: TtsReader__Preview__CacheDirectory
value: "/data/voice-previews"
# Sprint E XXL Phase 4γ — content-addressed CDN bundle dir for
# POST /api/v1/render. Default "wwwroot/cdn" resolves under the
# read-only app filesystem, so pin to the writable PVC mount
# alongside other TtsReader runtime data. Manifests + cue audio
# land at /data/cdn/sha256/<hash>/manifest.json + cues/.
- name: TtsReader__Render__CdnDirectory
value: "/data/cdn"
- name: Auth__ApiKey
valueFrom:
secretKeyRef:
name: ttsreader-secrets
key: Auth__ApiKey
optional: true
- name: Auth__AdminApiKey
valueFrom:
secretKeyRef:
name: ttsreader-secrets
key: Auth__AdminApiKey
optional: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
readinessProbe:
httpGet:
path: /health
port: 5217
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 5217
initialDelaySeconds: 15
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: ttsreader-data
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: ttsreader-piper
namespace: fc-ttsreader
spec:
selector:
app.kubernetes.io/name: ttsreader-piper
ports:
- port: 10200
targetPort: 10200
name: wyoming
---
apiVersion: v1
kind: Service
metadata:
name: ttsreader-web
namespace: fc-ttsreader
spec:
selector:
app.kubernetes.io/name: ttsreader-web
ports:
- port: 5217
targetPort: 5217
name: http
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ttsreader-piper-data
namespace: fc-ttsreader
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ttsreader-data
namespace: fc-ttsreader
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: ttsreader-cert
namespace: fc-ttsreader
spec:
secretName: ttsreader-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- ttsreader.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: ttsreader-web
namespace: fc-ttsreader
spec:
entryPoints:
- websecure
routes:
- match: Host(`ttsreader.iamworkin.lan`)
kind: Rule
services:
- name: ttsreader-web
port: 5217
tls:
secretName: ttsreader-tls

View File

@@ -0,0 +1,36 @@
# FlowerCore modern-tts — wraps Microsoft Edge's Read Aloud TTS service
# (via the edge-tts Python package) to give the cluster studio-quality
# Modern Hebrew (he-IL-*) and Modern Greek (el-GR-*) voices alongside the
# eSpeak biblical engine. Same shape as fc-biblical-tts so the .NET client
# lives in the same Shared.Speech package.
#
# Note: edge-tts depends on Microsoft's public Edge endpoint; the cluster
# pod needs egress to *.tts.speech.microsoft.com. dnsPolicy: None on the
# Deployment makes sure the iamworkin.lan template hijack doesn't rewrite
# the lookup back to Traefik VIP.
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py /app/
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1654 tts
USER 1654
EXPOSE 10403
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:10403/health',timeout=3); sys.exit(0)" || exit 1
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "10403", "--workers", "1"]

View File

@@ -0,0 +1,238 @@
"""FlowerCore modern-tts — Microsoft Edge Read Aloud bridge for Modern
Hebrew and Modern Greek (and other Edge-supported languages).
Endpoints:
* POST /tts — body: {"text", "voice", "rate"?, "volume"?, "pitch"?}
returns audio/mpeg (Edge returns MP3) which the
upstream FasterWhisperAlignmentClient + the WPF
MediaPlayer both handle natively.
* POST /timings — same body shape but returns
{"text", "voice", "words": [{"text","startMs","endMs"}],
"durationMs": ...} sourced from Edge's WordBoundary
events — much more accurate than eSpeak's
proportional-distribution approach because Edge
emits real per-word offsets during synthesis.
* GET /voices — voice catalog Edge knows about. Filtered to
Hebrew + Greek by default; ?language=all returns
everything Edge supports.
* GET /health — fast readiness check.
Pairs with fc-biblical-tts (eSpeak Ancient Greek + Hebrew). The biblical
engine handles unpointed Hebrew + Erasmian Greek; this engine handles
narrative Modern Hebrew + Modern Greek for translations the operator
might be reading alongside the original.
"""
from __future__ import annotations
import io
import logging
from typing import Optional
import edge_tts
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
LOG = logging.getLogger("modern_tts")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
app = FastAPI(title="FlowerCore modern-tts", version="1.0.0")
# Default voices by short code so AiStation can pick a sensible default
# when the operator hasn't explicitly asked for one. Edge has multiple
# voices per locale — these are the calmest male+female narrators.
DEFAULT_VOICES = {
"he": "he-IL-AvriNeural",
"he-IL": "he-IL-AvriNeural",
"el": "el-GR-NestorasNeural",
"el-GR": "el-GR-NestorasNeural",
"en": "en-US-AriaNeural",
}
class TtsRequest(BaseModel):
text: str
voice: Optional[str] = None
language: Optional[str] = None
rate: str = "+0%" # Edge accepts +20%, -10%, etc.
volume: str = "+0%"
pitch: str = "+0Hz"
def _resolve_voice(req: TtsRequest) -> str:
if req.voice:
return req.voice.strip()
if req.language and req.language in DEFAULT_VOICES:
return DEFAULT_VOICES[req.language]
return DEFAULT_VOICES["he"]
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/voices")
async def voices(language: str = "default"):
catalog = await edge_tts.list_voices()
if language == "all":
return {"voices": catalog}
# Default response: filter to languages relevant to the FlowerCore
# biblical workflow (Hebrew + Greek) so the AiStation voice picker
# isn't overwhelmed by 400+ Edge voices.
keep = ("he-", "el-")
filtered = [v for v in catalog if any(v.get("ShortName", "").startswith(k) for k in keep)]
return {"voices": filtered}
async def _synth_with_subtitles(req: TtsRequest):
voice = _resolve_voice(req)
LOG.info("edge-tts synth voice=%s len=%d", voice, len(req.text))
communicate = edge_tts.Communicate(
req.text,
voice=voice,
rate=req.rate,
volume=req.volume,
pitch=req.pitch,
)
audio_buf = io.BytesIO()
word_events: list[dict] = []
async for chunk in communicate.stream():
if chunk["type"] == "audio":
audio_buf.write(chunk["data"])
elif chunk["type"] == "WordBoundary":
word_events.append({
"text": chunk.get("text") or "",
"offset": chunk.get("offset", 0), # 100-ns ticks
"duration": chunk.get("duration", 0), # 100-ns ticks
})
return voice, audio_buf.getvalue(), word_events
def _to_ms(ticks_100ns: int) -> int:
# Edge emits offsets in 100-nanosecond ticks (.NET TimeSpan style).
return int(round(ticks_100ns / 10_000))
@app.post("/tts")
async def tts(req: TtsRequest):
if not req.text.strip():
raise HTTPException(status_code=400, detail="text is required")
try:
voice, audio_bytes, _ = await _synth_with_subtitles(req)
except edge_tts.exceptions.NoAudioReceived:
raise HTTPException(status_code=502, detail="edge-tts returned no audio for the supplied voice/text.")
except Exception as ex:
raise HTTPException(status_code=502, detail=f"edge-tts failure: {ex}")
if not audio_bytes:
raise HTTPException(status_code=502, detail="edge-tts returned an empty audio stream.")
return Response(content=audio_bytes, media_type="audio/mpeg",
headers={"X-FlowerCore-Voice": voice})
def _estimate_duration_ms_from_mp3(audio_bytes: bytes) -> int:
"""Best-effort duration estimate from raw MP3 bytes by walking frame
headers. Edge always returns CBR ~24kbps mono so we can infer total ms
from frame count. If parsing fails, return 0 and let the caller fall
through to a per-character heuristic."""
if not audio_bytes:
return 0
# MP3 sample rates by version+layer (MPEG1 layer3 / MPEG2 layer3 / MPEG2.5 layer3).
# We just walk frame headers and count frames; each frame is 1152 samples.
sample_rates_v1 = [44100, 48000, 32000, 0]
sample_rates_v2 = [22050, 24000, 16000, 0]
sample_rates_v25 = [11025, 12000, 8000, 0]
bitrates_v1_l3 = [0,32000,40000,48000,56000,64000,80000,96000,112000,128000,160000,192000,224000,256000,320000,0]
bitrates_v2_l3 = [0,8000,16000,24000,32000,40000,48000,56000,64000,80000,96000,112000,128000,144000,160000,0]
pos = 0
total_samples = 0
sample_rate = 0
while pos + 4 <= len(audio_bytes):
b0, b1, b2, b3 = audio_bytes[pos], audio_bytes[pos+1], audio_bytes[pos+2], audio_bytes[pos+3]
if b0 != 0xFF or (b1 & 0xE0) != 0xE0:
pos += 1
continue
version_bits = (b1 >> 3) & 0x03
layer_bits = (b1 >> 1) & 0x03
if layer_bits != 0x01: # layer 3 only
pos += 1
continue
bitrate_index = (b2 >> 4) & 0x0F
sample_rate_index = (b2 >> 2) & 0x03
padding = (b2 >> 1) & 0x01
if version_bits == 0x03: # MPEG1
sample_rate = sample_rates_v1[sample_rate_index]
bitrate = bitrates_v1_l3[bitrate_index]
samples_per_frame = 1152
elif version_bits == 0x02: # MPEG2
sample_rate = sample_rates_v2[sample_rate_index]
bitrate = bitrates_v2_l3[bitrate_index]
samples_per_frame = 576
elif version_bits == 0x00: # MPEG2.5
sample_rate = sample_rates_v25[sample_rate_index]
bitrate = bitrates_v2_l3[bitrate_index]
samples_per_frame = 576
else:
pos += 1
continue
if not (sample_rate and bitrate):
pos += 1
continue
frame_length = int((samples_per_frame * bitrate / 8) / sample_rate) + padding
if frame_length <= 0:
pos += 1
continue
total_samples += samples_per_frame
pos += frame_length
if sample_rate <= 0:
return 0
return int(round(total_samples * 1000 / sample_rate))
@app.post("/timings")
async def timings(req: TtsRequest):
if not req.text.strip():
raise HTTPException(status_code=400, detail="text is required")
try:
voice, audio_bytes, events = await _synth_with_subtitles(req)
except Exception as ex:
raise HTTPException(status_code=502, detail=f"edge-tts failure: {ex}")
words: list[dict] = []
for event in events:
start = _to_ms(event["offset"])
end = start + _to_ms(event["duration"])
words.append({"text": event.get("text", ""), "startMs": start, "endMs": end})
# Edge sometimes omits WordBoundary events for non-English voices
# (notably he-IL-* and el-GR-*). Fall back to proportional distribution
# over the input text — same approach the eSpeak biblical-tts uses.
if not words and req.text.strip():
total_ms = _estimate_duration_ms_from_mp3(audio_bytes)
if total_ms <= 0:
# Last-resort fallback: ~600ms per word at average speaking rate.
total_ms = max(1, len(req.text.split())) * 600
tokens = req.text.split()
if tokens:
char_total = sum(max(1, len(w)) for w in tokens)
cursor = 0
for token in tokens:
share = int(round(total_ms * max(1, len(token)) / char_total))
start = cursor
end = start + share
words.append({"text": token, "startMs": start, "endMs": end})
cursor = end
words[-1]["endMs"] = total_ms
duration_ms = words[-1]["endMs"] if words else 0
return JSONResponse({
"text": req.text,
"voice": voice,
"words": words,
"durationMs": duration_ms,
"audioBytes": len(audio_bytes),
})

View File

@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn==0.34.0
edge-tts==7.2.8

View File

@@ -0,0 +1,47 @@
# FlowerCore speech-align — wraps SYSTRAN/faster-whisper with /align +
# /transcribe endpoints used by FlowerCore.TtsReader. CPU-only image; the
# default int8 compute type runs base.en at ~real-time on a single core.
#
# Build: podman build -t localhost/fc-speech-align:<ver> .
# Run: podman run --rm -p 9200:9200 -v fc-speech-align-models:/models localhost/fc-speech-align:<ver>
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
WHISPER_MODEL=Systran/faster-whisper-base.en \
WHISPER_CACHE_DIR=/models \
WHISPER_DEVICE=cpu \
WHISPER_COMPUTE_TYPE=int8 \
DEFAULT_LANGUAGE=en \
MAX_AUDIO_BYTES=52428800
# faster-whisper depends on libsndfile1 + libgomp1 (OpenMP runtime). ffmpeg is
# pulled in for non-WAV inputs (transcribe accepts any container).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libsndfile1 \
libgomp1 \
ffmpeg \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py /app/
# Run as a non-root user to satisfy K8s securityContext.runAsNonRoot.
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1654 align \
&& mkdir -p /models \
&& chown -R 1654:1654 /models
USER 1654
EXPOSE 9200
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:9200/health',timeout=3); sys.exit(0)" || exit 1
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9200", "--workers", "1"]

View File

@@ -0,0 +1,181 @@
"""FlowerCore speech-align service.
Wraps SYSTRAN/faster-whisper (https://github.com/SYSTRAN/faster-whisper) in a
small FastAPI app exposing two endpoints:
* POST /align — fc-align contract used by FlowerCore.Shared.Speech's
FasterWhisperAlignmentClient on master. Multipart form
(`audio`, `language`) returns
`{text, words: [{word, startSeconds, endSeconds, confidence}],
durationMs, language}`.
* POST /transcribe — audio-file-in transcription used by the new TtsReader
audio-import feature. Multipart form (`audio`, optional
`language`) returns `{text, language, durationMs,
segments: [{startSeconds, endSeconds, text}]}` so the
UI can preview the transcript before piping it into
Quick Read or saving as a project.
Both endpoints share the same WhisperModel instance (loaded once at startup).
Model is pinned by the WHISPER_MODEL env var (defaults to base.en) and cached
under WHISPER_CACHE_DIR (defaults to /models, backed by a PVC in K8s).
Health: GET /health → {status: ok, model, device, computeType}.
"""
from __future__ import annotations
import io
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from faster_whisper import WhisperModel
LOG = logging.getLogger("speech_align")
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
MODEL_NAME = os.environ.get("WHISPER_MODEL", "Systran/faster-whisper-base.en")
DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "int8")
CACHE_DIR = os.environ.get("WHISPER_CACHE_DIR", "/models")
MAX_BYTES = int(os.environ.get("MAX_AUDIO_BYTES", str(50 * 1024 * 1024))) # 50 MB
DEFAULT_LANGUAGE = os.environ.get("DEFAULT_LANGUAGE", "en")
_state: dict[str, object] = {}
@asynccontextmanager
async def lifespan(_app: FastAPI):
LOG.info("Loading faster-whisper model %s (device=%s compute=%s cache=%s)", MODEL_NAME, DEVICE, COMPUTE_TYPE, CACHE_DIR)
started = time.time()
model = WhisperModel(MODEL_NAME, device=DEVICE, compute_type=COMPUTE_TYPE, download_root=CACHE_DIR)
_state["model"] = model
LOG.info("Model loaded in %.2fs", time.time() - started)
yield
_state.clear()
app = FastAPI(title="FlowerCore speech-align", version="1.0.0", lifespan=lifespan)
def _get_model() -> WhisperModel:
model = _state.get("model")
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded yet")
return model # type: ignore[return-value]
async def _read_upload(upload: UploadFile) -> bytes:
payload = await upload.read()
if not payload:
raise HTTPException(status_code=400, detail="audio is empty")
if len(payload) > MAX_BYTES:
raise HTTPException(
status_code=413,
detail=f"audio exceeds {MAX_BYTES} byte limit ({len(payload)} bytes received)",
)
return payload
def _normalize_language(value: Optional[str]) -> Optional[str]:
if not value or not value.strip():
return DEFAULT_LANGUAGE
return value.strip().lower()
def _transcribe_bytes(audio_bytes: bytes, language: Optional[str], word_timestamps: bool):
model = _get_model()
started = time.time()
segments_iter, info = model.transcribe(
io.BytesIO(audio_bytes),
language=language,
word_timestamps=word_timestamps,
beam_size=1,
vad_filter=True,
)
segments = list(segments_iter)
elapsed_ms = int((time.time() - started) * 1000)
return segments, info, elapsed_ms
@app.get("/health")
def health():
return {
"status": "ok" if _state.get("model") is not None else "loading",
"model": MODEL_NAME,
"device": DEVICE,
"computeType": COMPUTE_TYPE,
"defaultLanguage": DEFAULT_LANGUAGE,
"maxBytes": MAX_BYTES,
}
@app.post("/align")
async def align(audio: UploadFile = File(...), language: str = Form(DEFAULT_LANGUAGE)):
"""fc-align contract — used by FlowerCore.Shared.Speech.FasterWhisperAlignmentClient."""
payload = await _read_upload(audio)
lang = _normalize_language(language)
segments, info, elapsed_ms = _transcribe_bytes(payload, lang, word_timestamps=True)
text_parts: list[str] = []
words: list[dict] = []
for segment in segments:
text_parts.append(segment.text.strip())
for word in (segment.words or []):
# Field names MUST match the FlowerCore.Shared.Speech contract:
# `text` / `startMs` / `endMs`. The deployed FasterWhisperAlignmentClient
# ignores any other names — see Common's
# FasterWhisperAlignmentResponse / FasterWhisperWord.
words.append({
"text": word.word.strip(),
"startMs": int((word.start or 0.0) * 1000),
"endMs": int((word.end or 0.0) * 1000),
# Confidence is informational and ignored by the C# client today,
# but kept on the wire for future scoring + fc-align operators
# that want to surface low-confidence words.
"confidence": float(getattr(word, "probability", 0.0) or 0.0),
})
duration_ms = int((info.duration or 0.0) * 1000)
return JSONResponse({
"text": " ".join(p for p in text_parts if p).strip(),
"words": words,
"durationMs": duration_ms,
"language": info.language or lang,
"elapsedMs": elapsed_ms,
})
@app.post("/transcribe")
async def transcribe(audio: UploadFile = File(...), language: Optional[str] = Form(None)):
"""Audio-in transcription contract — used by the new TtsReader audio-import feature.
Returns full segments (no per-word timestamps) so the UI can preview the
transcript before piping it into Quick Read or saving as a project.
"""
payload = await _read_upload(audio)
lang = _normalize_language(language)
segments, info, elapsed_ms = _transcribe_bytes(payload, lang, word_timestamps=False)
out_segments = [
{
"startSeconds": float(segment.start or 0.0),
"endSeconds": float(segment.end or 0.0),
"text": segment.text.strip(),
}
for segment in segments
]
return JSONResponse({
"text": " ".join(s["text"] for s in out_segments if s["text"]).strip(),
"segments": out_segments,
"language": info.language or lang,
"durationMs": int((info.duration or 0.0) * 1000),
"elapsedMs": elapsed_ms,
})

View File

@@ -0,0 +1,8 @@
faster-whisper==1.0.3
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.10
# faster-whisper 1.0.3's utils module imports requests but doesn't pin it as a
# transitive dep — pin explicitly so the image isn't relying on whatever
# happens to be in the base image.
requests==2.32.3

View File

@@ -1,20 +1,20 @@
# Gitea Public IngressRoute
# Routes gitea.flowercore.io to internal Gitea service via Cloudflare origin cert
# ArgoCD managed - BlueJay Lab
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: gitea-public
namespace: gitea
spec:
entryPoints:
- websecure
routes:
- match: Host(`gitea.flowercore.io`)
kind: Rule
services:
- name: gitea-http
port: 3000
tls:
secretName: cf-origin-flowercore-io
# Gitea Public IngressRoute
# Routes gitea.flowercore.io to internal Gitea service via Cloudflare origin cert
# ArgoCD managed - BlueJay Lab
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: gitea-public
namespace: gitea
spec:
entryPoints:
- websecure
routes:
- match: Host(`gitea.flowercore.io`)
kind: Rule
services:
- name: gitea-http
port: 3000
tls:
secretName: cf-origin-flowercore-io

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
# UnrealIRCd + Anope IRC Services
# UnrealIRCd + Anope IRC Services + The Lounge web client
# ArgoCD managed - BlueJay Lab
# Credentials: 1Password → OnePasswordItem → K8s Secret → initContainer sed injection
---
@@ -32,6 +32,541 @@ spec:
dnsNames:
- irc.iamworkin.lan
---
# TLS Certificate for The Lounge web IRC
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: webirc-tls
namespace: irc
spec:
secretName: webirc-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- webirc.iamworkin.lan
---
# The Lounge configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: thelounge-config
namespace: irc
data:
config.js: |
"use strict";
module.exports = {
public: true,
host: "0.0.0.0",
port: 9000,
reverseProxy: true,
maxHistory: 2500,
theme: "thelounge-theme-flowercore",
prefetch: false,
disableMediaPreview: true,
fileUpload: {
enable: false
},
defaults: {
name: "BlueJayIRC",
host: "unrealircd.irc.svc",
port: 6667,
password: "",
tls: false,
rejectUnauthorized: true,
nick: "BlueJayWeb%%",
username: "bluejayweb",
realname: "BlueJay Web IRC",
join: "#general"
},
lockNetwork: true,
leaveMessage: "BlueJay Web IRC"
};
---
# FlowerCore / Blue Jay theme package for The Lounge
apiVersion: v1
kind: ConfigMap
metadata:
name: thelounge-flowercore-theme
namespace: irc
data:
package.json: |
{
"name": "thelounge-theme-flowercore",
"version": "1.0.0",
"description": "FlowerCore Blue Jay theme for The Lounge",
"main": "package.json",
"keywords": [
"thelounge",
"thelounge-theme"
],
"thelounge": {
"type": "theme",
"name": "FlowerCore Blue Jay",
"css": "theme.css",
"files": [
"bluejay-logo.svg",
"bluejay-bg.svg"
]
}
}
theme.css: |
:root {
--fc-bg: #0a1628;
--fc-surface: #111d33;
--fc-surface-2: #162844;
--fc-border: #1e3a5f;
--fc-accent: #2b8aff;
--fc-accent-soft: rgba(43, 138, 255, 0.22);
--fc-gold: #ffb300;
--fc-text: #e8edf5;
--fc-text-muted: #9db1c8;
--fc-success: #3db86a;
--fc-danger: #e84545;
--body-color: var(--fc-text);
--body-color-muted: var(--fc-text-muted);
--body-bg-color: var(--fc-bg);
--button-color: var(--fc-accent);
--button-text-color-hover: #ffffff;
--overlay-bg-color: rgba(5, 12, 22, 0.84);
--link-color: #8bc3ff;
--window-bg-color: var(--fc-surface);
--window-heading-color: #f5f8ff;
--date-marker-color: rgba(43, 138, 255, 0.45);
--unread-marker-color: rgba(255, 179, 0, 0.6);
--highlight-bg-color: rgba(43, 138, 255, 0.12);
--highlight-border-color: var(--fc-gold);
--upload-progressbar-color: var(--fc-gold);
}
body {
background:
radial-gradient(circle at top right, rgba(43, 138, 255, 0.22), transparent 30%),
linear-gradient(145deg, rgba(10, 22, 40, 0.98), rgba(12, 25, 46, 0.98)),
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
color: var(--fc-text);
font-family: "Trebuchet MS", "Segoe UI", Verdana, sans-serif;
}
a,
a:focus,
a:hover {
color: var(--link-color);
}
.window,
#confirm-dialog,
#context-menu,
.mentions-popup,
.textcomplete-menu {
background: rgba(17, 29, 51, 0.97);
border: 1px solid var(--fc-border);
box-shadow: 0 24px 60px rgba(3, 10, 18, 0.55);
}
#loading .window,
#confirm-dialog {
background:
linear-gradient(180deg, rgba(14, 30, 54, 0.98), rgba(17, 29, 51, 0.98)),
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
border-color: rgba(43, 138, 255, 0.4);
}
#loading .logo,
#loading .logo-inverted,
#sidebar .logo,
#sidebar .logo-inverted {
display: none !important;
}
#loading-status-container,
#sidebar .logo-container {
position: relative;
}
#loading-status-container::before {
content: "";
display: block;
width: 96px;
height: 96px;
margin: 0 auto 16px;
background: url("/packages/thelounge-theme-flowercore/bluejay-logo.svg") center / contain no-repeat;
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.45));
}
#loading-page-message::before {
content: "FlowerCore IRC";
display: block;
margin-bottom: 10px;
color: #ffffff;
font-size: 30px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
#loading-page-message::after {
content: "Blue Jay web chat for iamworkin.lan";
display: block;
margin-top: 10px;
color: var(--fc-text-muted);
font-size: 14px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
#loading-page-message {
color: var(--fc-text);
font-size: 15px;
line-height: 1.7;
max-width: 30rem;
text-align: center;
}
#sidebar {
background:
linear-gradient(180deg, rgba(10, 22, 40, 0.98), rgba(17, 29, 51, 0.98)),
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
border-right: 1px solid var(--fc-border);
color: #d5e3f5;
}
#sidebar .logo-container {
padding: 20px 14px 8px;
}
#sidebar .logo-container::before {
content: "";
display: block;
width: 72px;
height: 72px;
margin: 0 auto 10px;
background: url("/packages/thelounge-theme-flowercore/bluejay-logo.svg") center / contain no-repeat;
filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.35));
}
#sidebar .logo-container::after {
content: "FlowerCore IRC";
display: block;
color: #ffffff;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.06em;
text-align: center;
text-transform: uppercase;
}
#sidebar .network {
margin-bottom: 16px;
}
.channel-list-item,
#footer button {
border-radius: 10px;
transition: background-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
}
.channel-list-item:hover,
#footer button:hover {
background: rgba(43, 138, 255, 0.12);
color: #ffffff;
}
.channel-list-item.active,
#footer button.active {
background: linear-gradient(90deg, rgba(43, 138, 255, 0.24), rgba(22, 40, 68, 0.92));
box-shadow: inset 3px 0 0 var(--fc-gold);
color: #ffffff;
}
.channel-list-item[data-type="lobby"] {
color: #8bc3ff;
}
.channel-list-item .badge {
background: rgba(255, 255, 255, 0.08);
color: var(--fc-text-muted);
}
.channel-list-item .badge.highlight {
background: var(--fc-gold);
color: #08111e;
font-weight: 700;
}
#footer {
background: rgba(10, 22, 40, 0.92);
border-top: 1px solid var(--fc-border);
}
#viewport .lt,
#viewport .rt,
#chat button.close,
#chat button.menu,
#chat button.mentions,
#chat button.search,
#form #submit,
#form #upload,
.password-container .reveal-password span {
color: #8bc3ff;
}
#viewport .lt:hover,
#viewport .rt:hover,
#chat button.close:hover,
#chat button.menu:hover,
#chat button.mentions:hover,
#chat button.search:hover,
#form #submit:hover,
#form #upload:hover,
.password-container .reveal-password span:hover {
background: rgba(43, 138, 255, 0.16);
border-radius: 8px;
color: #ffffff;
opacity: 1;
}
#chat .header {
background: linear-gradient(135deg, #0e1e36, #1a3a6a, #2b8aff);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #ffffff;
}
.header .title,
.header .topic,
#chat .header button {
color: #ffffff;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
}
#chat .messages {
background:
linear-gradient(180deg, rgba(17, 29, 51, 0.98), rgba(11, 21, 39, 0.98)),
radial-gradient(circle at top right, rgba(43, 138, 255, 0.08), transparent 35%);
}
#chat .msg {
border-radius: 6px;
transition: background-color 0.2s ease;
}
#chat .msg:hover {
background: rgba(255, 255, 255, 0.03);
}
#chat .chat-view[data-type="channel"] .msg.highlight,
.mentions-popup .msg .content {
background: linear-gradient(90deg, rgba(43, 138, 255, 0.16), rgba(255, 179, 0, 0.1));
box-shadow: inset 3px 0 0 var(--fc-gold);
}
#chat .msg-statusmsg {
background: rgba(255, 179, 0, 0.18);
color: #ffe4a0;
}
#chat .msg[data-type="monospace_block"] .text {
background: rgba(8, 17, 30, 0.94);
border: 1px solid rgba(43, 138, 255, 0.22);
border-radius: 10px;
box-shadow: inset 3px 0 0 var(--fc-gold);
color: var(--fc-text);
display: inline-block;
line-height: 1.7;
max-width: min(100%, 44rem);
padding: 10px 12px;
white-space: pre-wrap;
}
#chat .msg[data-command="motd"] .text {
background:
linear-gradient(180deg, rgba(14, 30, 54, 0.96), rgba(8, 17, 30, 0.96)),
url("/packages/thelounge-theme-flowercore/bluejay-bg.svg");
border-color: rgba(255, 179, 0, 0.28);
color: #f4f8ff;
}
#chat .msg[data-command="motd"] .from {
color: #9dd3ff;
font-weight: 700;
}
#chat .msg[data-command="motd"] a {
color: #8bc3ff;
font-weight: 700;
}
#chat .userlist,
#form,
.mentions-popup,
.textcomplete-menu,
#context-menu {
background: rgba(17, 29, 51, 0.98);
border-color: var(--fc-border);
}
#chat .userlist .count,
#chat .user-mode:before {
background: rgba(8, 17, 30, 0.95);
}
.input,
#connect input,
#connect select,
#settings input,
#settings select,
#settings textarea,
#form #nick,
#chat .userlist .search,
form.message-search input,
.jump-to-input .input,
.password-container input {
background: rgba(8, 17, 30, 0.72);
border: 1px solid var(--fc-border);
border-radius: 10px;
color: var(--fc-text);
}
#form {
border-top: 1px solid var(--fc-border);
padding: 8px;
}
#form #nick {
color: #8bc3ff;
line-height: 28px;
}
#form #input {
margin: 0 8px;
min-height: 36px;
padding: 8px 10px;
}
::placeholder,
.jump-to-input .input::placeholder,
form.message-search input::placeholder {
color: rgba(232, 237, 245, 0.45);
}
.jump-to-input:before,
#chat .count:before {
color: rgba(232, 237, 245, 0.45);
}
.btn {
background: linear-gradient(180deg, rgba(24, 62, 112, 0.28), rgba(14, 30, 54, 0.38));
border-color: var(--fc-accent);
border-radius: 999px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
color: var(--fc-text);
letter-spacing: 0.12em;
}
.btn:hover,
.btn:focus,
.btn:disabled {
background: linear-gradient(180deg, #2b8aff, #1c6fe3);
color: #ffffff;
}
.btn:active,
.btn:focus,
.input:focus {
box-shadow: 0 0 0 3px rgba(43, 138, 255, 0.26);
}
#version-checker,
#settings .settings-sync-panel,
#connect .connect-sasl-external {
background: rgba(8, 17, 30, 0.78);
border: 1px solid var(--fc-border);
color: var(--fc-text);
}
#version-checker.loading {
border-left: 3px solid var(--fc-accent);
color: #9dd3ff;
}
#version-checker.up-to-date {
border-left: 3px solid var(--fc-success);
color: #87e9a9;
}
#version-checker.new-packages,
#version-checker.new-version {
border-left: 3px solid var(--fc-gold);
color: #ffe4a0;
}
#version-checker.error,
#settings .error,
#sign-in .error {
border-left: 3px solid var(--fc-danger);
color: #ffb3b3;
}
#upload-progressbar {
box-shadow: 0 0 14px rgba(255, 179, 0, 0.75);
}
::-webkit-scrollbar:hover {
background-color: rgba(255, 255, 255, 0.04);
}
::-webkit-scrollbar-thumb:vertical {
background: linear-gradient(180deg, rgba(43, 138, 255, 0.72), rgba(30, 58, 95, 0.95));
}
::-webkit-scrollbar-thumb:vertical:active {
background: linear-gradient(180deg, rgba(255, 179, 0, 0.85), rgba(43, 138, 255, 0.9));
}
@media (max-width: 768px) {
#sidebar {
box-shadow: 0 0 28px rgba(0, 0, 0, 0.5);
}
#chat .header {
padding-right: 6px;
}
}
bluejay-logo.svg: |
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<ellipse cx="14" cy="18" rx="10" ry="9" fill="#3B7BC0"/>
<ellipse cx="12" cy="20" rx="6" ry="6" fill="#C8DFFF"/>
<path d="M10 14 C8 10, 16 8, 22 12 C24 14, 22 18, 18 18 C14 18, 10 16, 10 14Z" fill="#2B5A8A"/>
<path d="M12 12.5 L21 11" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
<circle cx="22" cy="11" r="6" fill="#3B7BC0"/>
<path d="M20 6 L22 1 L24 3 L22 7Z" fill="#2B5A8A"/>
<path d="M21 5 L22.5 2 L23.5 4Z" fill="#5BA3FF"/>
<path d="M16 14 C18 16, 22 16, 26 14" stroke="#0d1520" stroke-width="1.8" fill="none" stroke-linecap="round"/>
<circle cx="24" cy="10" r="2" fill="#fff"/>
<circle cx="24.5" cy="9.5" r="1" fill="#0d1520"/>
<path d="M27 11 L31 11.5 L27 13Z" fill="#364b6b"/>
<path d="M4 16 L1 13 L3 17 L1 20 L5 18Z" fill="#2B5A8A"/>
<circle cx="10" cy="23" r="2" fill="#FFB300"/>
<circle cx="10" cy="23" r="1" fill="#FFCA40"/>
</svg>
bluejay-bg.svg: |
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<defs>
<pattern id="hex" x="0" y="0" width="60" height="52" patternUnits="userSpaceOnUse">
<path d="M30 0 L60 15 L60 37 L30 52 L0 37 L0 15 Z" fill="none" stroke="rgba(43,138,255,0.04)" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="200" height="200" fill="url(#hex)"/>
<circle cx="30" cy="0" r="1" fill="rgba(255,179,0,0.06)"/>
<circle cx="90" cy="0" r="1" fill="rgba(255,179,0,0.06)"/>
<circle cx="150" cy="0" r="1" fill="rgba(255,179,0,0.06)"/>
<circle cx="0" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
<circle cx="60" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
<circle cx="120" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
<circle cx="180" cy="52" r="1" fill="rgba(43,138,255,0.06)"/>
</svg>
---
# UnrealIRCd configuration template (passwords replaced by placeholders)
apiVersion: v1
kind: ConfigMap
@@ -178,6 +713,15 @@ data:
server allow;
}
}
ircd.motd: |
- BlueJay IRC -
Welcome to BlueJayIRC on iamworkin.lan.
Web IRC: https://webirc.iamworkin.lan
Channels: #general, #ops, #alerts
Keep it keyboard-first, practical, and kind.
Managed from bluejay-infra via ArgoCD.
---
# Anope configuration template (passwords replaced by placeholders)
apiVersion: v1
@@ -195,7 +739,7 @@ data:
uplink
{
host = "unrealircd.irc.svc.cluster.local"
host = "unrealircd.irc.svc"
port = 8067
password = "__LINK_PASSWORD__"
}
@@ -444,6 +988,8 @@ metadata:
app: unrealircd
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: unrealircd
@@ -506,6 +1052,10 @@ spec:
- name: injected-config
mountPath: /app/conf/unrealircd.conf
subPath: unrealircd.conf
- name: unrealircd-config-template
mountPath: /app/conf/ircd.motd
subPath: ircd.motd
readOnly: true
- name: unrealircd-data
mountPath: /app/data
- name: irc-tls
@@ -614,6 +1164,82 @@ spec:
persistentVolumeClaim:
claimName: anope-data
---
# The Lounge web IRC Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: thelounge
namespace: irc
labels:
app: thelounge
spec:
replicas: 1
selector:
matchLabels:
app: thelounge
template:
metadata:
labels:
app: thelounge
spec:
initContainers:
- name: install-flowercore-theme
image: ghcr.io/thelounge/thelounge:4.4.3
command:
- sh
- -lc
- |
set -eu
THELOUNGE_HOME=/var/opt/thelounge thelounge install file:/flowercore-theme
volumeMounts:
- name: thelounge-config
mountPath: /var/opt/thelounge/config.js
subPath: config.js
- name: thelounge-packages
mountPath: /var/opt/thelounge/packages
- name: thelounge-flowercore-theme
mountPath: /flowercore-theme
containers:
- name: thelounge
image: ghcr.io/thelounge/thelounge:4.4.3
ports:
- containerPort: 9000
name: http
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 20
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 256Mi
cpu: 250m
volumeMounts:
- name: thelounge-config
mountPath: /var/opt/thelounge/config.js
subPath: config.js
- name: thelounge-packages
mountPath: /var/opt/thelounge/packages
volumes:
- name: thelounge-config
configMap:
name: thelounge-config
- name: thelounge-packages
emptyDir: {}
- name: thelounge-flowercore-theme
configMap:
name: thelounge-flowercore-theme
---
# UnrealIRCd Service
apiVersion: v1
kind: Service
@@ -648,6 +1274,20 @@ spec:
targetPort: 8067
name: services-link
---
# The Lounge web IRC Service
apiVersion: v1
kind: Service
metadata:
name: thelounge
namespace: irc
spec:
selector:
app: thelounge
ports:
- port: 9000
targetPort: 9000
name: http
---
# Traefik IngressRouteTCP - IRC plain (6667)
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
@@ -679,3 +1319,21 @@ spec:
port: 6697
tls:
passthrough: true
---
# Traefik IngressRoute - The Lounge web IRC
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: webirc
namespace: irc
spec:
entryPoints:
- websecure
routes:
- match: Host(`webirc.iamworkin.lan`)
kind: Rule
services:
- name: thelounge
port: 9000
tls:
secretName: webirc-tls

165
apps/knowledge/README.md Normal file
View File

@@ -0,0 +1,165 @@
# knowledge — FlowerCore.Knowledge.Web (Phase 2.4 K8s deploy)
**Status:** **LIVE 2026-04-27** at `https://knowledge.iamworkin.lan`
Phase 2.4 closed. Pod running, certificate issued (step-ca-acme), PVC
bound (Longhorn 20Gi RWO), ArgoCD `infra-knowledge` synced. `/healthz`
returns 200, `/api/v1/editions` returns `[]` (initial-deploy state — no
*.db files in the PVC yet; Phase 2.5+ admin UI handles bulk
population). Phase 1 of the Agent Zero MCP rollout keeps `/healthz`
anonymous and gates `/mcp` behind `Authorization: Bearer <token>` built
from the 1Password item `FlowerCore Knowledge MCP Tokens`.
- Plan: [`../../../FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md`](../../../FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md)
- Sprint: [`../../../FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md`](../../../FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md) (Track B)
- Repo: `D:\git\FlowerCore\FlowerCore.Knowledge\` (private GitHub repo,
bootstrapped Sprint D batch 35)
`FlowerCore.Knowledge.Web` is the fleet-wide vector-indexing & RAG hub —
a REST + MCP service that scans `*.db` files under
`/data/vector-stores` and exposes per-edition reachability + corpus
search to the rest of the FC ecosystem (Agent Zero, Chat.Web persona
memory, AiStation embeddings explorer, TtsReader chapter context, BMO
bot, Pi nodes via `fc-index sync`).
Phase 1 MCP routing is explicit:
- in-cluster Agent Zero → `http://knowledge-web.knowledge.svc/mcp`
- workstation Agent Zero → `https://knowledge.iamworkin.lan/mcp`
- probe URL for both lanes → `/healthz`
## Deployment order (do NOT skip / reorder)
### 1. FlowerCore.DNS public A record — knowledge.iamworkin.lan -> 10.0.56.200
Required BEFORE the Certificate resource is created, or cert-manager
HTTP-01 silently backs off ~2h. Memory: `feedback_pfsense_dns_required_for_acme`.
The canonical path is FlowerCore.DNS:
```bash
curl -sk https://dns.iamworkin.lan/api/v1/servers
# Find the pfSense serverId, then create the record using the host label only.
curl -sk -X POST https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iamworkin.lan/records \
-H "Content-Type: application/json" \
-d '{"name":"knowledge","type":"A","data":"10.0.56.200","ttl":300}'
```
If FlowerCore.DNS provider writes are failing 502 with "pfSense
diag_command.php response did not contain a `<pre>` block" (status as of
Sprint E Track B authoring 2026-04-27), add the override manually via
the pfSense web UI:
1. Log in to `https://10.0.56.1` as admin
2. Services → DNS Resolver → General Settings → Host Overrides
3. Add: Host=`knowledge`, Domain=`iamworkin.lan`, IP Address=`10.0.56.200`
4. Save + Apply Changes
Verify resolution from anywhere on LAN:
```bash
nslookup knowledge.iamworkin.lan 10.0.56.1
# Expect: 10.0.56.200
```
Or against FlowerCore.DNS once the provider is fixed:
```bash
curl -sk "https://dns.iamworkin.lan/api/v1/zones/iamworkin.lan/resolve-preflight?hostname=knowledge.iamworkin.lan"
# Expect: "resolvable": true
```
### 2. Build + import the image to ALL RKE2 nodes
Pods may schedule on any RKE2 worker (server, agent1, agent2). The
Longhorn PVC accepts mounts from any node, so the image must be
imported to all three. Memory:
`feedback_rke2_image_import_targets_all_nodes` +
`feedback_rke2_localhost_imagepullpolicy`.
```bash
# From BLUEJAY-WS, in D:\git\FlowerCore\FlowerCore.Knowledge
TAG="v$(date +%Y%m%d%H%M)"
dotnet.exe publish -c Release -o deploy/app \
src/FlowerCore.Knowledge.Web/FlowerCore.Knowledge.Web.csproj
podman build -t localhost/fc-knowledge-web:$TAG -f deploy/Dockerfile.deploy deploy
podman save localhost/fc-knowledge-web:$TAG -o /tmp/fc-knowledge-web.tar
# Import to all three RKE2 nodes
for node in rke2-server rke2-agent1 rke2-agent2; do
scp /tmp/fc-knowledge-web.tar $node:/tmp/
ssh $node "sudo /var/lib/rancher/rke2/bin/ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images import /tmp/fc-knowledge-web.tar"
done
```
The repo's `scripts/deploy-knowledge.sh` automates this loop.
### 3. Bump the image tag + push
Edit `knowledge.yaml`, replace `localhost/fc-knowledge-web:v202604272200`
with the tag from step 2, then:
```bash
cd D:/git/FlowerCore/bluejay-infra
python scripts/check-pfsense-dns.py # confirms the DNS preflight
git add apps/knowledge/
git commit -m "feat(knowledge): deploy Phase 2.4 K8s manifest"
git push
```
ArgoCD picks up within ~3 minutes and creates `infra-knowledge`.
### 4. Verify
```bash
fcadmin_ssh noc1 '
kubectl -n argocd get application infra-knowledge
kubectl -n knowledge get certificate,pod,pvc
curl -sk -m 8 -o /dev/null -w "HTTP %{http_code}\n" https://knowledge.iamworkin.lan/healthz
curl -sk -m 8 https://knowledge.iamworkin.lan/api/v1/editions | jq
'
```
Expect: Certificate `Ready: True` within ~60s, `/healthz` HTTP 200,
`/api/v1/editions` returns an empty array (no DBs in the PVC yet) on
first deploy.
## Initial-deploy state and Phase 2.5 follow-up
The Longhorn PVC is empty on first deploy. Knowledge.Web's filesystem
catalog will report zero editions until vector-store `*.db` files are
pushed into `/data/vector-stores`. Initial population is a follow-up
step (Phase 2.5+, Blazor admin UI's "Rebuild" button); for the first
deploy the goal is just to prove the pod boots, `/healthz` returns 200,
and the Traefik IngressRoute serves the Scalar UI.
To copy an existing local DB into the PVC (one-time, manual until
Phase 2.5 admin UI lands):
```bash
fcadmin_ssh noc1 '
POD=$(kubectl -n knowledge get pod -l app=knowledge-web -o jsonpath="{.items[0].metadata.name}")
kubectl -n knowledge cp /var/lib/flowercore/vector-stores/bluejay-ai.db $POD:/data/vector-stores/bluejay-ai.db
'
```
## Probes + middleware notes
- `/healthz` is mapped by `Controllers/HealthController.cs` (controller-based
attribute route). Cheap — no DB, no dependencies.
- Liveness uses `tcpSocket` as a defensive fallback in case future
middleware accidentally gates `/healthz` behind auth (memory:
`feedback_k8s_probes_behind_auth_middleware`).
- `/openapi/v1.json` and `/scalar/v1` are wired by `UseFlowerCoreApi`.
Per memory `feedback_k8s_probes_must_not_hit_openapi`, probes must NOT
point at OpenAPI documents — the `MapOpenApi` call can be slow during
cold startup.
## Resource sizing
- 256Mi memory request / 1Gi limit.
- 100m CPU request / 1000m limit.
- 20Gi Longhorn PVC initial — sufficient for the bluejay-ai 1.94Gi DB +
fleet-pi-edge 352Mi + fleet-bmo-bot 141Mi + headroom. Resize via
`kubectl -n knowledge edit pvc knowledge-vector-store` if growing
past 15Gi.

View File

@@ -0,0 +1,262 @@
# FlowerCore.Knowledge.Web — fleet vector indexing & RAG hub.
#
# Phase 2.4 of the Knowledge service plan. REST + MCP service that scans
# *.db files under /data/vector-stores and exposes:
# - REST: /api/v1/editions, /api/v1/corpus/search, /healthz
# - MCP: list_editions, describe_edition, corpus_search
# - Static OpenAPI/Scalar via UseFlowerCoreApi
#
# Architecture:
# Plan: FlowerCore.Notes/docs/ai-agents/flowercore-knowledge-service-plan.md
# Sprint: FlowerCore.Notes/docs/ai-station/sprint-e-xxl-plan.md (Track B)
# Repo: D:\git\FlowerCore\FlowerCore.Knowledge\
# Shared: FlowerCore.Common -> FlowerCore.Shared.Indexing (chunkers, vector
# stores, edition profiles, ICorpusSearchService facade)
#
# Deployment order (see apps/knowledge/README.md and the bluejay-infra/README.md
# top-level checklist):
# 1. FlowerCore.DNS public A record knowledge.iamworkin.lan -> 10.0.56.200
# MUST exist BEFORE the Certificate is created, or cert-manager HTTP-01
# backs off ~2h. Memory: feedback_pfsense_dns_required_for_acme.
# 2. Build + import the image to ALL RKE2 nodes (server + both agents) since
# the Pod uses a Longhorn PVC and may schedule anywhere.
# Memory: feedback_rke2_localhost_imagepullpolicy.
# 3. Bump the image tag in this file, git push.
# 4. ArgoCD ApplicationSet picks up within ~3 minutes and creates
# infra-knowledge.
#
# Initial-deploy state:
# The Longhorn PVC is empty on first deploy. Knowledge.Web's filesystem
# catalog will report zero editions until vector-store *.db files are
# pushed into /data/vector-stores. Initial population is a follow-up step
# (Phase 2.5+, Blazor admin UI's "Rebuild" button); for the first deploy
# the goal is just to prove the pod boots, /healthz returns 200, and the
# Traefik IngressRoute serves the Scalar UI.
---
apiVersion: v1
kind: Namespace
metadata:
name: knowledge
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# MCP bearer token for the read-only Agent Zero Phase 1 lane. The 1Password
# item currently stores the raw token in its concealed PASSWORD field, which
# the operator syncs into the namespaced Secret key `password`.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: knowledge-mcp-tokens
namespace: knowledge
spec:
itemPath: "vaults/IAmWorkin/items/FlowerCore Knowledge MCP Tokens"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: knowledge-vector-store
namespace: knowledge
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: knowledge-web
namespace: knowledge
labels:
app: knowledge-web
app.kubernetes.io/name: knowledge-web
app.kubernetes.io/part-of: bluejay-infra
spec:
replicas: 1
revisionHistoryLimit: 3
# RWO Longhorn PVC blocks rolling updates (multi-attach error). Recreate
# is the canonical pattern (memory: feedback_rwo_pvc_blocks_rolling).
strategy:
type: Recreate
selector:
matchLabels:
app: knowledge-web
template:
metadata:
labels:
app: knowledge-web
app.kubernetes.io/name: knowledge-web
app.kubernetes.io/part-of: bluejay-infra
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
securityContext:
runAsNonRoot: true
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: web
# Placeholder tag — bump to the image you built + imported to ALL
# RKE2 nodes via scripts/deploy-knowledge.sh before applying.
image: localhost/fc-knowledge-web:v20260429232635
imagePullPolicy: Never
command:
- /bin/sh
- -c
args:
- |
if [ -n "${KNOWLEDGE_MCP_BEARER_TOKEN:-}" ]; then
export FlowerCore__Mcp__ApiKey__Key="Bearer ${KNOWLEDGE_MCP_BEARER_TOKEN}"
fi
exec dotnet FlowerCore.Knowledge.Web.dll
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_URLS
value: "http://+:8080"
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false"
# Vector-store directory + embedding model + edition profile dir.
# Profile JSON is baked into the image at /home/app/editions via the
# csproj Content-link from FlowerCore.Common/editions/.
- name: Knowledge__VectorStoresDirectory
value: "/data/vector-stores"
- name: Knowledge__EmbeddingModel
value: "nomic-embed-text"
- name: Knowledge__DefaultLimit
value: "5"
- name: Knowledge__MaxLimit
value: "50"
- name: FlowerCore__Editions__ProfileDirectory
value: "/home/app/editions"
# Embed via edge1 Pi 5 + AI HAT+ (10.0.57.17:11434). Cluster
# services do not depend on BLUEJAY-WS (private dev hardware) per
# bluejay-infra@0f9d56e. Query-time embedding is fast enough on
# edge1 (~ms per query); bulk index rebuilds (Phase 2.5+) will
# need a separate ingestion lane that can opt into the
# workstation GPU when present.
- name: FlowerCore__Ollama__BaseUrl
value: "http://10.0.57.17:11434"
- name: FlowerCore__Mcp__ApiKey__Key
valueFrom:
secretKeyRef:
name: knowledge-mcp-tokens
key: password
- name: FlowerCore__Mcp__ApiKey__HeaderName
value: "Authorization"
- name: KNOWLEDGE_MCP_BEARER_TOKEN
valueFrom:
secretKeyRef:
name: knowledge-mcp-tokens
key: password
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
# /healthz is mapped by HealthController (controller-based route).
# tcpSocket liveness is the defensive fallback in case middleware
# later gates /healthz behind auth (memory:
# feedback_k8s_probes_behind_auth_middleware).
startupProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: vector-store
mountPath: /data/vector-stores
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /home/app/logs
volumes:
- name: vector-store
persistentVolumeClaim:
claimName: knowledge-vector-store
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: knowledge-web
namespace: knowledge
labels:
app: knowledge-web
app.kubernetes.io/name: knowledge-web
app.kubernetes.io/part-of: bluejay-infra
spec:
type: ClusterIP
selector:
app: knowledge-web
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: knowledge-tls
namespace: knowledge
spec:
secretName: knowledge-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- knowledge.iamworkin.lan
duration: 2160h # 90d
renewBefore: 720h # 30d
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: knowledge
namespace: knowledge
spec:
entryPoints:
- websecure
routes:
- match: Host(`knowledge.iamworkin.lan`)
kind: Rule
services:
- name: knowledge-web
port: 80
tls:
secretName: knowledge-tls

View File

@@ -0,0 +1,7 @@
# ArgoCD's bluejay-infra ApplicationSet uses a directory generator and does
# not require kustomization.yaml. Mirrors the fc-distribution shape so
# `kubectl kustomize` previews work from a working copy.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- knowledge.yaml

View File

@@ -1,259 +1,259 @@
# docker-mailserver - Postfix + Dovecot + rspamd
# ArgoCD managed - BlueJay Lab
# Credentials: 1Password → OnePasswordItem CRD → K8s Secret
---
apiVersion: v1
kind: Namespace
metadata:
name: mail
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# 1Password → K8s Secret sync for mail credentials
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: mail-credentials
namespace: mail
spec:
itemPath: "vaults/IAmWorkin/items/Mail Postmaster"
---
# Mail data PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mail-data
namespace: mail
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
---
# Mail state PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mail-state
namespace: mail
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
# docker-mailserver Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: mailserver
namespace: mail
labels:
app: mailserver
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: mailserver
template:
metadata:
labels:
app: mailserver
spec:
hostname: mail
initContainers:
- name: inject-accounts
image: busybox:1.36
command:
- sh
- -c
- |
ADMIN_EMAIL=$(cat /credentials/Admin-Email)
ADMIN_HASH=$(cat /credentials/Admin-Hash)
NOREPLY_EMAIL=$(cat /credentials/Noreply-Email)
NOREPLY_HASH=$(cat /credentials/Noreply-Hash)
echo "${ADMIN_EMAIL}|${ADMIN_HASH}" > /accounts/postfix-accounts.cf
echo "${NOREPLY_EMAIL}|${NOREPLY_HASH}" >> /accounts/postfix-accounts.cf
volumeMounts:
- name: mail-credentials
mountPath: /credentials
readOnly: true
- name: mail-accounts-generated
mountPath: /accounts
containers:
- name: mailserver
image: docker.io/mailserver/docker-mailserver:latest
ports:
- containerPort: 25
name: smtp
- containerPort: 465
name: smtps
- containerPort: 587
name: submission
- containerPort: 143
name: imap
- containerPort: 993
name: imaps
env:
- name: ENABLE_SPAMASSASSIN
value: "1"
- name: ENABLE_CLAMAV
value: "0"
- name: ENABLE_RSPAMD
value: "1"
- name: TZ
value: America/Chicago
- name: POSTMASTER_ADDRESS
value: postmaster@iamwork.in
- name: OVERRIDE_HOSTNAME
value: mail.iamwork.in
- name: ENABLE_FAIL2BAN
value: "0"
- name: ENABLE_POSTGREY
value: "0"
- name: ONE_DIR
value: "1"
- name: PERMIT_DOCKER
value: network
- name: SSL_TYPE
value: manual
- name: SSL_CERT_PATH
value: /etc/ssl/mail/tls.crt
- name: SSL_KEY_PATH
value: /etc/ssl/mail/tls.key
- name: ACCOUNT_PROVISIONER
value: FILE
volumeMounts:
- name: mail-data
mountPath: /var/mail
- name: mail-state
mountPath: /var/mail-state
- name: mail-tls
mountPath: /etc/ssl/mail
readOnly: true
- name: mail-accounts-generated
mountPath: /tmp/docker-mailserver/postfix-accounts.cf
subPath: postfix-accounts.cf
readOnly: true
resources:
requests:
memory: 512Mi
cpu: 200m
limits:
memory: 2Gi
cpu: "1"
securityContext:
capabilities:
add:
- NET_ADMIN
- SYS_PTRACE
volumes:
- name: mail-data
persistentVolumeClaim:
claimName: mail-data
- name: mail-state
persistentVolumeClaim:
claimName: mail-state
- name: mail-tls
secret:
secretName: mail-tls
- name: mail-credentials
secret:
secretName: mail-credentials
- name: mail-accounts-generated
emptyDir: {}
---
# SMTP LoadBalancer Service (external)
apiVersion: v1
kind: Service
metadata:
name: mail-smtp
namespace: mail
annotations:
metallb.universe.tf/loadBalancerIPs: 10.0.56.202
spec:
type: LoadBalancer
selector:
app: mailserver
ports:
- port: 25
targetPort: 25
name: smtp
protocol: TCP
- port: 465
targetPort: 465
name: smtps
protocol: TCP
- port: 587
targetPort: 587
name: submission
protocol: TCP
---
# IMAP ClusterIP Service (internal)
apiVersion: v1
kind: Service
metadata:
name: mail-imap
namespace: mail
spec:
selector:
app: mailserver
ports:
- port: 143
targetPort: 143
name: imap
- port: 993
targetPort: 993
name: imaps
---
# TLS Certificate via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mail-tls
namespace: mail
spec:
secretName: mail-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- mail.iamworkin.lan
---
# Traefik IngressRoute - Webmail placeholder
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: mail-webmail
namespace: mail
spec:
entryPoints:
- websecure
routes:
- match: Host(`mail.iamworkin.lan`)
kind: Rule
services:
- name: mail-imap
port: 993
tls:
secretName: mail-tls
---
# Public IngressRoute - Webmail (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: mail-webmail-public
namespace: mail
spec:
entryPoints:
- websecure
routes:
- match: Host(`webmail.flowercore.io`)
kind: Rule
services:
- name: mail-webmail
port: 8080
tls:
secretName: cf-origin-flowercore-io
# docker-mailserver - Postfix + Dovecot + rspamd
# ArgoCD managed - BlueJay Lab
# Credentials: 1Password → OnePasswordItem CRD → K8s Secret
---
apiVersion: v1
kind: Namespace
metadata:
name: mail
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# 1Password → K8s Secret sync for mail credentials
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: mail-credentials
namespace: mail
spec:
itemPath: "vaults/IAmWorkin/items/Mail Postmaster"
---
# Mail data PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mail-data
namespace: mail
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
---
# Mail state PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mail-state
namespace: mail
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
# docker-mailserver Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: mailserver
namespace: mail
labels:
app: mailserver
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: mailserver
template:
metadata:
labels:
app: mailserver
spec:
hostname: mail
initContainers:
- name: inject-accounts
image: busybox:1.36
command:
- sh
- -c
- |
ADMIN_EMAIL=$(cat /credentials/Admin-Email)
ADMIN_HASH=$(cat /credentials/Admin-Hash)
NOREPLY_EMAIL=$(cat /credentials/Noreply-Email)
NOREPLY_HASH=$(cat /credentials/Noreply-Hash)
echo "${ADMIN_EMAIL}|${ADMIN_HASH}" > /accounts/postfix-accounts.cf
echo "${NOREPLY_EMAIL}|${NOREPLY_HASH}" >> /accounts/postfix-accounts.cf
volumeMounts:
- name: mail-credentials
mountPath: /credentials
readOnly: true
- name: mail-accounts-generated
mountPath: /accounts
containers:
- name: mailserver
image: docker.io/mailserver/docker-mailserver:latest
ports:
- containerPort: 25
name: smtp
- containerPort: 465
name: smtps
- containerPort: 587
name: submission
- containerPort: 143
name: imap
- containerPort: 993
name: imaps
env:
- name: ENABLE_SPAMASSASSIN
value: "1"
- name: ENABLE_CLAMAV
value: "0"
- name: ENABLE_RSPAMD
value: "1"
- name: TZ
value: America/Chicago
- name: POSTMASTER_ADDRESS
value: postmaster@iamwork.in
- name: OVERRIDE_HOSTNAME
value: mail.iamwork.in
- name: ENABLE_FAIL2BAN
value: "0"
- name: ENABLE_POSTGREY
value: "0"
- name: ONE_DIR
value: "1"
- name: PERMIT_DOCKER
value: network
- name: SSL_TYPE
value: manual
- name: SSL_CERT_PATH
value: /etc/ssl/mail/tls.crt
- name: SSL_KEY_PATH
value: /etc/ssl/mail/tls.key
- name: ACCOUNT_PROVISIONER
value: FILE
volumeMounts:
- name: mail-data
mountPath: /var/mail
- name: mail-state
mountPath: /var/mail-state
- name: mail-tls
mountPath: /etc/ssl/mail
readOnly: true
- name: mail-accounts-generated
mountPath: /tmp/docker-mailserver/postfix-accounts.cf
subPath: postfix-accounts.cf
readOnly: true
resources:
requests:
memory: 512Mi
cpu: 200m
limits:
memory: 2Gi
cpu: "1"
securityContext:
capabilities:
add:
- NET_ADMIN
- SYS_PTRACE
volumes:
- name: mail-data
persistentVolumeClaim:
claimName: mail-data
- name: mail-state
persistentVolumeClaim:
claimName: mail-state
- name: mail-tls
secret:
secretName: mail-tls
- name: mail-credentials
secret:
secretName: mail-credentials
- name: mail-accounts-generated
emptyDir: {}
---
# SMTP LoadBalancer Service (external)
apiVersion: v1
kind: Service
metadata:
name: mail-smtp
namespace: mail
annotations:
metallb.universe.tf/loadBalancerIPs: 10.0.56.202
spec:
type: LoadBalancer
selector:
app: mailserver
ports:
- port: 25
targetPort: 25
name: smtp
protocol: TCP
- port: 465
targetPort: 465
name: smtps
protocol: TCP
- port: 587
targetPort: 587
name: submission
protocol: TCP
---
# IMAP ClusterIP Service (internal)
apiVersion: v1
kind: Service
metadata:
name: mail-imap
namespace: mail
spec:
selector:
app: mailserver
ports:
- port: 143
targetPort: 143
name: imap
- port: 993
targetPort: 993
name: imaps
---
# TLS Certificate via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mail-tls
namespace: mail
spec:
secretName: mail-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- mail.iamworkin.lan
---
# Traefik IngressRoute - Webmail placeholder
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: mail-webmail
namespace: mail
spec:
entryPoints:
- websecure
routes:
- match: Host(`mail.iamworkin.lan`)
kind: Rule
services:
- name: mail-imap
port: 993
tls:
secretName: mail-tls
---
# Public IngressRoute - Webmail (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: mail-webmail-public
namespace: mail
spec:
entryPoints:
- websecure
routes:
- match: Host(`webmail.flowercore.io`)
kind: Rule
services:
- name: mail-webmail
port: 8080
tls:
secretName: cf-origin-flowercore-io

View File

@@ -1,495 +1,506 @@
# Matrix Synapse + Element Web
# PostgreSQL 16 + Synapse homeserver + Element Web client
# ArgoCD managed - BlueJay Lab
# DB credentials sourced from 1Password via OnePasswordItem CRD (matrix-credentials)
# Synapse homeserver.yaml DB password injected at runtime via init container
---
apiVersion: v1
kind: Namespace
metadata:
name: matrix
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Synapse homeserver.yaml template ConfigMap
# DB password placeholder __DB_PASSWORD__ is replaced at pod startup by init container
apiVersion: v1
kind: ConfigMap
metadata:
name: synapse-config
namespace: matrix
data:
homeserver.yaml.template: |
server_name: "iamworkin.lan"
pid_file: /data/homeserver.pid
public_baseurl: "https://matrix.iamworkin.lan/"
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ["0.0.0.0"]
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: __DB_USER__
password: __DB_PASSWORD__
database: synapse
host: matrix-postgres
port: 5432
cp_min: 5
cp_max: 10
log_config: "/config/log.config"
media_store_path: /data/media_store
registration_shared_secret: "a208f2e4b260f6b7d6ff4566df49c56c8b73fa20b911ce4e617b791ee7868adc"
report_stats: false
macaroon_secret_key: "9964f398e8b48a91469ad419d293c06db4562f49df8cc6e129fb3a801fd9052d"
form_secret: "7b0a9dbaf9ee94450e0b3271c408dfc4d313a55843ce4eec2ac1bb0315ffeb76"
signing_key_path: "/data/signing.key"
trusted_key_servers:
- server_name: "matrix.org"
enable_registration: false
suppress_key_server_warning: true
log.config: |
version: 1
formatters:
precise:
format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s"
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
level: WARNING
root:
level: WARNING
handlers: [console]
disable_existing_loggers: false
---
# PostgreSQL 16 StatefulSet
# Credentials from 1Password-synced matrix-credentials secret
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: matrix-postgres
# Matrix Synapse + Element Web
# PostgreSQL 16 + Synapse homeserver + Element Web client
# ArgoCD managed - BlueJay Lab
# DB credentials sourced from 1Password via OnePasswordItem CRD (matrix-credentials)
# Synapse homeserver.yaml DB password injected at runtime via init container
---
apiVersion: v1
kind: Namespace
metadata:
name: matrix
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Synapse homeserver.yaml template ConfigMap
# DB password placeholder __DB_PASSWORD__ is replaced at pod startup by init container
apiVersion: v1
kind: ConfigMap
metadata:
name: synapse-config
namespace: matrix
data:
homeserver.yaml.template: |
server_name: "iamworkin.lan"
pid_file: /data/homeserver.pid
public_baseurl: "https://matrix.iamworkin.lan/"
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ["0.0.0.0"]
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: __DB_USER__
password: __DB_PASSWORD__
database: synapse
host: matrix-postgres
port: 5432
cp_min: 5
cp_max: 10
log_config: "/config/log.config"
media_store_path: /data/media_store
registration_shared_secret: "a208f2e4b260f6b7d6ff4566df49c56c8b73fa20b911ce4e617b791ee7868adc"
report_stats: false
macaroon_secret_key: "9964f398e8b48a91469ad419d293c06db4562f49df8cc6e129fb3a801fd9052d"
form_secret: "7b0a9dbaf9ee94450e0b3271c408dfc4d313a55843ce4eec2ac1bb0315ffeb76"
signing_key_path: "/data/signing.key"
trusted_key_servers:
- server_name: "matrix.org"
enable_registration: false
suppress_key_server_warning: true
log.config: |
version: 1
formatters:
precise:
format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s"
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
level: WARNING
root:
level: WARNING
handlers: [console]
disable_existing_loggers: false
---
# PostgreSQL 16 StatefulSet
# Credentials from 1Password-synced matrix-credentials secret
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: matrix-postgres
namespace: matrix
labels:
app: matrix-postgres
argocd.argoproj.io/instance: infra-matrix
spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
podManagementPolicy: OrderedReady
serviceName: matrix-postgres
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app: matrix-postgres
template:
metadata:
labels:
app: matrix-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-Password
- name: POSTGRES_DB
value: synapse
- name: POSTGRES_INITDB_ARGS
value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumeMounts:
- name: matrix-postgres-data
mountPath: /var/lib/postgresql/data
subPath: pgdata
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
livenessProbe:
exec:
command: ["pg_isready", "-U", "synapse"]
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command: ["pg_isready", "-U", "synapse"]
initialDelaySeconds: 5
periodSeconds: 5
volumeClaimTemplates:
- metadata:
name: matrix-postgres-data
spec:
accessModes: [ReadWriteOnce]
template:
metadata:
labels:
app: matrix-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-Password
- name: POSTGRES_DB
value: synapse
- name: POSTGRES_INITDB_ARGS
value: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumeMounts:
- name: matrix-postgres-data
mountPath: /var/lib/postgresql/data
subPath: pgdata
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
livenessProbe:
exec:
command: ["pg_isready", "-U", "synapse"]
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command: ["pg_isready", "-U", "synapse"]
initialDelaySeconds: 5
periodSeconds: 5
volumeClaimTemplates:
- metadata:
name: matrix-postgres-data
spec:
accessModes: [ReadWriteOnce]
volumeMode: Filesystem
resources:
requests:
storage: 5Gi
updateStrategy:
rollingUpdate:
partition: 0
type: RollingUpdate
---
apiVersion: v1
kind: Service
metadata:
name: matrix-postgres
namespace: matrix
spec:
selector:
app: matrix-postgres
ports:
- port: 5432
targetPort: 5432
name: postgres
clusterIP: None
---
# Synapse Data PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: synapse-data
namespace: matrix
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 2Gi
---
# Synapse Deployment
# Init container injects DB credentials from 1Password secret into homeserver.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: synapse
namespace: matrix
labels:
app: synapse
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: synapse
template:
metadata:
labels:
app: synapse
spec:
initContainers:
- name: generate-signing-key
image: matrixdotorg/synapse:latest
securityContext:
runAsUser: 0
command: ["sh", "-c"]
args:
- |
if [ \! -f /data/signing.key ]; then
python -m synapse.app.homeserver --generate-keys --config-path /config-template/homeserver.yaml.template 2>/dev/null || true
# If key generation fails with template, create a minimal config for key gen
if [ \! -f /data/signing.key ]; then
echo server_name: iamworkin.lan > /tmp/minimal.yaml
echo signing_key_path: /data/signing.key >> /tmp/minimal.yaml
python -c "from signedjson.key import generate_signing_key, write_signing_keys; import sys; key = generate_signing_key(a_auto); write_signing_keys(open(/data/signing.key,w), [key])" 2>/dev/null || true
fi
fi
chown 991:991 /data/signing.key 2>/dev/null || true
chmod 644 /data/signing.key 2>/dev/null || true
mkdir -p /data/media_store
chown -R 991:991 /data 2>/dev/null || true
volumeMounts:
- name: synapse-data
mountPath: /data
- name: synapse-config-template
mountPath: /config-template
- name: inject-credentials
image: busybox:latest
command: ["sh", "-c"]
args:
- |
# Copy template and substitute DB credentials from 1Password secret
cp /config-template/log.config /config/log.config
sed -e "s/__DB_PASSWORD__/${DB_PASSWORD}/g" \
-e "s/__DB_USER__/${DB_USER}/g" \
/config-template/homeserver.yaml.template > /config/homeserver.yaml
echo "Credentials injected into homeserver.yaml"
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-Password
- name: DB_USER
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-User
volumeMounts:
- name: synapse-config-template
mountPath: /config-template
- name: synapse-config-rendered
mountPath: /config
containers:
- name: synapse
image: matrixdotorg/synapse:latest
ports:
- containerPort: 8008
name: http
env:
- name: SYNAPSE_CONFIG_DIR
value: /config
- name: SYNAPSE_CONFIG_PATH
value: /config/homeserver.yaml
volumeMounts:
- name: synapse-data
mountPath: /data
- name: synapse-config-rendered
mountPath: /config
resources:
requests:
memory: 512Mi
cpu: 200m
limits:
memory: 2Gi
cpu: "1"
livenessProbe:
httpGet:
path: /health
port: 8008
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8008
initialDelaySeconds: 30
periodSeconds: 5
volumes:
- name: synapse-data
persistentVolumeClaim:
claimName: synapse-data
- name: synapse-config-template
configMap:
name: synapse-config
- name: synapse-config-rendered
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: synapse
namespace: matrix
spec:
selector:
app: synapse
ports:
- port: 8008
targetPort: 8008
name: http
---
# Element Web ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: element-web-config
namespace: matrix
data:
config.json: |
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.iamworkin.lan",
"server_name": "iamworkin.lan"
}
},
"brand": "BlueJay Chat",
"disable_guests": true,
"disable_3pid_login": true
}
---
# Element Web Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: element-web
namespace: matrix
labels:
app: element-web
spec:
replicas: 1
selector:
matchLabels:
app: element-web
template:
metadata:
labels:
app: element-web
spec:
enableServiceLinks: false
containers:
- name: element-web
image: vectorim/element-web:latest
ports:
- containerPort: 80
name: http
volumeMounts:
- name: element-config
mountPath: /app/config.json
subPath: config.json
resources:
requests:
memory: 32Mi
cpu: 10m
limits:
memory: 128Mi
cpu: 100m
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: element-config
configMap:
name: element-web-config
---
apiVersion: v1
kind: Service
metadata:
name: element-web
namespace: matrix
spec:
selector:
app: element-web
ports:
- port: 80
targetPort: 80
name: http
---
# TLS Certificates via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: matrix-tls
namespace: matrix
spec:
secretName: matrix-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- matrix.iamworkin.lan
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: element-tls
namespace: matrix
spec:
secretName: element-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- element.iamworkin.lan
---
# Traefik IngressRoute - Synapse
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: synapse
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`matrix.iamworkin.lan`)
kind: Rule
services:
- name: synapse
port: 8008
tls:
secretName: matrix-tls
---
# Traefik IngressRoute - Element Web
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: element-web
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`element.iamworkin.lan`)
kind: Rule
services:
- name: element-web
port: 80
tls:
secretName: element-tls
---
# 1Password secret sync — creates matrix-credentials K8s Secret
# Fields: DB-User, DB-Password, Registration-Secret, username, password, URL
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: matrix-credentials
namespace: matrix
spec:
itemPath: vaults/IAmWorkin/items/Matrix Synapse
---
# Public IngressRoute - Element Web (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: element-public
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`element.flowercore.io`)
kind: Rule
services:
- name: element-web
port: 80
tls:
secretName: cf-origin-flowercore-io
---
# Public IngressRoute - Synapse (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: synapse-public
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`matrix.flowercore.io`)
kind: Rule
services:
- name: synapse
port: 8008
tls:
secretName: cf-origin-flowercore-io
metadata:
name: matrix-postgres
namespace: matrix
spec:
selector:
app: matrix-postgres
ports:
- port: 5432
targetPort: 5432
name: postgres
clusterIP: None
---
# Synapse Data PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: synapse-data
namespace: matrix
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 2Gi
---
# Synapse Deployment
# Init container injects DB credentials from 1Password secret into homeserver.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: synapse
namespace: matrix
labels:
app: synapse
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: synapse
template:
metadata:
labels:
app: synapse
spec:
initContainers:
- name: generate-signing-key
image: matrixdotorg/synapse:latest
securityContext:
runAsUser: 0
command: ["sh", "-c"]
args:
- |
if [ \! -f /data/signing.key ]; then
python -m synapse.app.homeserver --generate-keys --config-path /config-template/homeserver.yaml.template 2>/dev/null || true
# If key generation fails with template, create a minimal config for key gen
if [ \! -f /data/signing.key ]; then
echo server_name: iamworkin.lan > /tmp/minimal.yaml
echo signing_key_path: /data/signing.key >> /tmp/minimal.yaml
python -c "from signedjson.key import generate_signing_key, write_signing_keys; import sys; key = generate_signing_key(a_auto); write_signing_keys(open(/data/signing.key,w), [key])" 2>/dev/null || true
fi
fi
chown 991:991 /data/signing.key 2>/dev/null || true
chmod 644 /data/signing.key 2>/dev/null || true
mkdir -p /data/media_store
chown -R 991:991 /data 2>/dev/null || true
volumeMounts:
- name: synapse-data
mountPath: /data
- name: synapse-config-template
mountPath: /config-template
- name: inject-credentials
image: busybox:latest
command: ["sh", "-c"]
args:
- |
# Copy template and substitute DB credentials from 1Password secret
cp /config-template/log.config /config/log.config
sed -e "s/__DB_PASSWORD__/${DB_PASSWORD}/g" \
-e "s/__DB_USER__/${DB_USER}/g" \
/config-template/homeserver.yaml.template > /config/homeserver.yaml
echo "Credentials injected into homeserver.yaml"
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-Password
- name: DB_USER
valueFrom:
secretKeyRef:
name: matrix-credentials
key: DB-User
volumeMounts:
- name: synapse-config-template
mountPath: /config-template
- name: synapse-config-rendered
mountPath: /config
containers:
- name: synapse
image: matrixdotorg/synapse:latest
ports:
- containerPort: 8008
name: http
env:
- name: SYNAPSE_CONFIG_DIR
value: /config
- name: SYNAPSE_CONFIG_PATH
value: /config/homeserver.yaml
volumeMounts:
- name: synapse-data
mountPath: /data
- name: synapse-config-rendered
mountPath: /config
resources:
requests:
memory: 512Mi
cpu: 200m
limits:
memory: 2Gi
cpu: "1"
livenessProbe:
httpGet:
path: /health
port: 8008
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8008
initialDelaySeconds: 30
periodSeconds: 5
volumes:
- name: synapse-data
persistentVolumeClaim:
claimName: synapse-data
- name: synapse-config-template
configMap:
name: synapse-config
- name: synapse-config-rendered
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: synapse
namespace: matrix
spec:
selector:
app: synapse
ports:
- port: 8008
targetPort: 8008
name: http
---
# Element Web ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: element-web-config
namespace: matrix
data:
config.json: |
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.iamworkin.lan",
"server_name": "iamworkin.lan"
}
},
"brand": "BlueJay Chat",
"disable_guests": true,
"disable_3pid_login": true
}
---
# Element Web Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: element-web
namespace: matrix
labels:
app: element-web
spec:
replicas: 1
selector:
matchLabels:
app: element-web
template:
metadata:
labels:
app: element-web
spec:
enableServiceLinks: false
containers:
- name: element-web
image: vectorim/element-web:latest
ports:
- containerPort: 80
name: http
volumeMounts:
- name: element-config
mountPath: /app/config.json
subPath: config.json
resources:
requests:
memory: 32Mi
cpu: 10m
limits:
memory: 128Mi
cpu: 100m
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: element-config
configMap:
name: element-web-config
---
apiVersion: v1
kind: Service
metadata:
name: element-web
namespace: matrix
spec:
selector:
app: element-web
ports:
- port: 80
targetPort: 80
name: http
---
# TLS Certificates via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: matrix-tls
namespace: matrix
spec:
secretName: matrix-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- matrix.iamworkin.lan
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: element-tls
namespace: matrix
spec:
secretName: element-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- element.iamworkin.lan
---
# Traefik IngressRoute - Synapse
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: synapse
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`matrix.iamworkin.lan`)
kind: Rule
services:
- name: synapse
port: 8008
tls:
secretName: matrix-tls
---
# Traefik IngressRoute - Element Web
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: element-web
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`element.iamworkin.lan`)
kind: Rule
services:
- name: element-web
port: 80
tls:
secretName: element-tls
---
# 1Password secret sync — creates matrix-credentials K8s Secret
# Fields: DB-User, DB-Password, Registration-Secret, username, password, URL
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: matrix-credentials
namespace: matrix
spec:
itemPath: vaults/IAmWorkin/items/Matrix Synapse
---
# Public IngressRoute - Element Web (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: element-public
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`element.flowercore.io`)
kind: Rule
services:
- name: element-web
port: 80
tls:
secretName: cf-origin-flowercore-io
---
# Public IngressRoute - Synapse (flowercore.io with Cloudflare origin cert)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: synapse-public
namespace: matrix
spec:
entryPoints:
- websecure
routes:
- match: Host(`matrix.flowercore.io`)
kind: Rule
services:
- name: synapse
port: 8008
tls:
secretName: cf-origin-flowercore-io

View File

@@ -0,0 +1,249 @@
# Grafana dashboard ConfigMap for FlowerCore.RemoteDesktop.
#
# Inlines the JSON from flowercore-remotedesktop-grafana-dashboard.json.
# Kept as a standalone file (not inlined in noc-monitoring.yaml) so the
# CRLF-dirty state of noc-monitoring.yaml doesn't have to be normalized
# in the same pass. To actually load the dashboard, the Grafana Deployment
# in noc-monitoring.yaml needs a matching 'volumes:' entry:
#
# - name: dashboard-remotedesktop
# configMap:
# name: grafana-dashboard-remotedesktop
#
# ArgoCD will sync this ConfigMap automatically through the bluejay-infra
# ApplicationSet (infra-monitoring App). The dashboard just won't load
# until the Grafana Deployment mount is wired.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-remotedesktop
namespace: monitoring
data:
remotedesktop.json: |
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"editorMode": "code",
"expr": "sum by (event) (increase(fc_desktop_session_events_total[$__rate_interval]))",
"legendFormat": "{{event}}",
"range": true,
"refId": "A"
}
],
"title": "RemoteDesktop Session Events",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showUnfilled": true
},
"targets": [
{
"editorMode": "code",
"expr": "sum by (template, event) (increase(fc_desktop_session_events_total[24h]))",
"legendFormat": "{{template}} {{event}}",
"range": true,
"refId": "A"
}
],
"title": "24h Session Events By Template",
"type": "bargauge"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"editorMode": "code",
"expr": "fc_desktop_pool_ready",
"legendFormat": "{{template}} ready",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "fc_desktop_pool_desired",
"legendFormat": "{{template}} desired",
"range": true,
"refId": "B"
}
],
"title": "Warm Pool Ready vs Desired",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"editorMode": "code",
"expr": "sum(increase(fc_desktop_session_events_total{event=\"connect\",browser_datasource=\"json\"}[24h])) - sum(increase(fc_desktop_session_events_total{event=\"disconnect\"}[24h]))",
"range": true,
"refId": "A"
}
],
"title": "24h Connect Minus Disconnect",
"type": "stat"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": [
"flowercore",
"remotedesktop",
"guacamole"
],
"templating": {
"list": []
},
"time": {
"from": "now-24h",
"to": "now"
},
"timezone": "browser",
"title": "FlowerCore RemoteDesktop",
"uid": "flowercore-remotedesktop",
"version": 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,257 +1,318 @@
# NOC Services - Traefik IngressRoutes for noc1 services
# Proxies internal .iamworkin.lan hostnames to noc1 (10.0.56.10) via
# headless Service + manual Endpoints (standard K8s external proxy pattern)
# ArgoCD managed - BlueJay Lab
---
apiVersion: v1
kind: Namespace
metadata:
name: noc-proxy
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# ============================================================
# BasicAuth - shared across all NOC proxy IngressRoutes
# ============================================================
apiVersion: v1
kind: Secret
metadata:
name: noc-proxy-auth
namespace: noc-proxy
type: Opaque
data:
users: YWRtaW46JDJiJDEwJEZjdlVFNWNpNkxvNi5rZ1k5L3hJV2V5M2tvM3VVY1U5YXJaSlQ4N29ZREtCSi5lNkoucXJD
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: noc-proxy-auth
namespace: noc-proxy
spec:
basicAuth:
secret: noc-proxy-auth
---
# ============================================================
# Grafana - noc1:3000
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: grafana-external
namespace: noc-proxy
spec:
ports:
- port: 3000
targetPort: 3000
name: http
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: grafana-external
namespace: noc-proxy
subsets:
- addresses:
- ip: 10.0.56.10
ports:
- port: 3000
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: grafana-tls
namespace: noc-proxy
spec:
secretName: grafana-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- grafana.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: grafana
namespace: noc-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`grafana.iamworkin.lan`)
middlewares:
- name: noc-proxy-auth
services:
- name: grafana-external
port: 3000
tls:
secretName: grafana-tls
---
# ============================================================
# Prometheus - noc1:9091
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: prometheus-external
namespace: noc-proxy
spec:
ports:
- port: 9091
targetPort: 9091
name: http
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: prometheus-external
namespace: noc-proxy
subsets:
- addresses:
- ip: 10.0.56.10
ports:
- port: 9091
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: prometheus-tls
namespace: noc-proxy
spec:
secretName: prometheus-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- prometheus.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: prometheus
namespace: noc-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`prometheus.iamworkin.lan`)
middlewares:
- name: noc-proxy-auth
services:
- name: prometheus-external
port: 9091
tls:
secretName: prometheus-tls
---
# ============================================================
# Cockpit - noc1:9090
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: cockpit-external
namespace: noc-proxy
spec:
ports:
- port: 9090
targetPort: 9090
name: https
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: cockpit-external
namespace: noc-proxy
subsets:
- addresses:
- ip: 10.0.56.10
ports:
- port: 9090
name: https
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: cockpit-tls
namespace: noc-proxy
spec:
secretName: cockpit-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- cockpit.iamworkin.lan
---
# Cockpit uses self-signed HTTPS on 9090, so we need a ServersTransport
# to skip backend TLS verification
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: cockpit-transport
namespace: noc-proxy
spec:
insecureSkipVerify: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: cockpit
namespace: noc-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`cockpit.iamworkin.lan`)
middlewares:
- name: noc-proxy-auth
services:
- name: cockpit-external
port: 9090
serversTransport: cockpit-transport
tls:
secretName: cockpit-tls
---
# NetworkPolicy: allow Traefik ingress, allow egress to noc1
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: noc-proxy-netpol
namespace: noc-proxy
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
egress:
- to:
- ipBlock:
cidr: 10.0.56.10/32
ports:
- port: 3000
protocol: TCP
- port: 9090
protocol: TCP
- port: 9091
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# NOC Services - Traefik IngressRoutes for noc1 services
# Proxies internal .iamworkin.lan hostnames to noc1 (10.0.56.10) via
# headless Service + manual Endpoints (standard K8s external proxy pattern)
# ArgoCD managed - BlueJay Lab
---
apiVersion: v1
kind: Namespace
metadata:
name: noc-proxy
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# ============================================================
# BasicAuth - shared across all NOC proxy IngressRoutes
# ============================================================
apiVersion: v1
kind: Secret
metadata:
name: noc-proxy-auth
namespace: noc-proxy
type: Opaque
data:
users: YWRtaW46JDJiJDEwJEZjdlVFNWNpNkxvNi5rZ1k5L3hJV2V5M2tvM3VVY1U5YXJaSlQ4N29ZREtCSi5lNkoucXJD
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: noc-proxy-auth
namespace: noc-proxy
spec:
basicAuth:
secret: noc-proxy-auth
---
# ============================================================
# Grafana - noc1:3000
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: grafana-external
namespace: noc-proxy
spec:
ports:
- port: 3000
targetPort: 3000
name: http
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: grafana-external
namespace: noc-proxy
subsets:
- addresses:
- ip: 10.0.56.10
ports:
- port: 3000
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: grafana-tls
namespace: noc-proxy
spec:
secretName: grafana-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- grafana.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: grafana
namespace: noc-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`grafana.iamworkin.lan`)
middlewares:
- name: noc-proxy-auth
services:
- name: grafana-external
port: 3000
tls:
secretName: grafana-tls
---
# ============================================================
# Prometheus - noc1:9091
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: prometheus-external
namespace: noc-proxy
spec:
ports:
- port: 9091
targetPort: 9091
name: http
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: prometheus-external
namespace: noc-proxy
subsets:
- addresses:
- ip: 10.0.56.10
ports:
- port: 9091
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: prometheus-tls
namespace: noc-proxy
spec:
secretName: prometheus-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- prometheus.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: prometheus
namespace: noc-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`prometheus.iamworkin.lan`)
middlewares:
- name: noc-proxy-auth
services:
- name: prometheus-external
port: 9091
tls:
secretName: prometheus-tls
---
# ============================================================
# Cockpit - noc1:9090
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: cockpit-external
namespace: noc-proxy
spec:
ports:
- port: 9090
targetPort: 9090
name: https
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: cockpit-external
namespace: noc-proxy
subsets:
- addresses:
- ip: 10.0.56.10
ports:
- port: 9090
name: https
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: cockpit-tls
namespace: noc-proxy
spec:
secretName: cockpit-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- cockpit.iamworkin.lan
---
# Cockpit uses self-signed HTTPS on 9090, so we need a ServersTransport
# to skip backend TLS verification
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: cockpit-transport
namespace: noc-proxy
spec:
insecureSkipVerify: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: cockpit
namespace: noc-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`cockpit.iamworkin.lan`)
middlewares:
- name: noc-proxy-auth
services:
- name: cockpit-external
port: 9090
serversTransport: cockpit-transport
tls:
secretName: cockpit-tls
---
# ============================================================
# PuppetDB Dashboard - noc1:8080 (HTTP, web UI only)
# Agent-to-PuppetDB mTLS still uses port 8081 directly via Puppet CA
# (NOT via this proxy). See docs/infrastructure/cert-recovery-2026-04-28.md
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: puppetdb-external
namespace: noc-proxy
spec:
ports:
- port: 8080
targetPort: 8080
name: http
clusterIP: None
---
apiVersion: v1
kind: Endpoints
metadata:
name: puppetdb-external
namespace: noc-proxy
subsets:
- addresses:
- ip: 10.0.56.10
ports:
- port: 8080
name: http
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: puppetdb-tls
namespace: noc-proxy
spec:
secretName: puppetdb-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- puppetdb.iamworkin.lan
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: puppetdb
namespace: noc-proxy
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`puppetdb.iamworkin.lan`)
services:
- name: puppetdb-external
port: 8080
tls:
secretName: puppetdb-tls
---
# NetworkPolicy: allow Traefik ingress, allow egress to noc1
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: noc-proxy-netpol
namespace: noc-proxy
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
egress:
- to:
- ipBlock:
cidr: 10.0.56.10/32
ports:
- port: 3000
protocol: TCP
- port: 8080
protocol: TCP
- port: 9090
protocol: TCP
- port: 9091
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP

View File

@@ -1,145 +1,145 @@
# TeamSpeak 3 Server
# ArgoCD managed - BlueJay Lab
---
apiVersion: v1
kind: Namespace
metadata:
name: teamspeak
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# 1Password secret sync - TeamSpeak credentials
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: teamspeak-credentials
namespace: teamspeak
spec:
itemPath: "vaults/IAmWorkin/items/TeamSpeak 3"
---
# TeamSpeak data PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: teamspeak-data
namespace: teamspeak
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
# TeamSpeak license key
apiVersion: v1
kind: Secret
metadata:
name: teamspeak-license
namespace: teamspeak
type: Opaque
data:
licensekey.dat: Y29tcGFueSBuYW1lIDogQW5kcmV3IFN0b2x0egphZGRyZXNzICAgICAgOiA2NjI1IDE2Mm5kIHQKemlwY29kZSAgICAgIDogNTUwNjgKY2l0eSAgICAgICAgIDogTGFrZXZpbGxlCmNvdW50cnkgICAgICA6IFVuaXRlZCBTdGF0ZXMgb2YgQW1lcmljYQpwaG9uZSAgICAgICAgOiA5NTI5OTk2NDExCmZheCAgICAgICAgICA6IApzYWxlcyBjb250YWN0OiBBbmRyZXcgU3RvbHR6IChhc3RvbHR6QGlhbXdvcmsuaW4pCnRlY2ggY29udGFjdCA6IEFuZHJldyBTdG9sdHogKGFzdG9sdHpAaWFtd29yay5pbikKCnRzIHZlcnNpb24gICA6IDMKdHlwZSAgICAgICAgIDogQWN0aXZhdGlvbiBMaWNlbnNlCnN0YXJ0IGRhdGUgICA6IFN1biBNYXIgIDggMDA6MDA6MDAgMjAyNgplbmQgZGF0ZSAgICAgOiBNb24gTWFyICA4IDAwOjAwOjAwIDIwMjcKbWF4LiB2aXJ0dWFsIHNlcnZlcnM6IDEKbWF4LiBzbG90cyAgIDogMzIKZGVzY3JpcHRpb24gIDogVGVhbVNwZWFrIDMgQUwKCgo9PWtleTI9PQpDb0VDQ3NnQkFRQ3ZiSEZUUURZL3RlclBlaWxycC9FQ1U5eENINVUzeEM5MmxZVE5hWS8wS1FBSkZ1ZUFhemJzZ0FBQUFDVlVaV0Z0VTNCbFlXc2dVM2x6ZEdWdGN5QkhiV0pJQUFCTnozMW0vRVdKU2w4QjFtcjJ2anZvdjRIL2s2UE9KVGJLb2NzVnoxRTNCUUFZd1c3S1B0R0k4QUFBQUNSVVpXRnRVM0JsWVdzZ2MzbHpkR1Z0Y3lCSGJXSklBQUFXSlVXQkErRjQzTERsMzFxS0NpOWw4WWdpQmhyOXlIWW1rbTFvWVVTMlhnSVl5cFVBR3F2SWdBWUFBQUFBUVc1a2NtVjNJRk4wYjJ4MGVnQVNJTmp2OStDVDQwTTUvQWdlY0lLcW1Gb2hyZnAzd3dSbGVSL25Ia3kvdHRKcEdDQWdBU29PVkdWaGJWTndaV0ZySURNZ1FVd1N0UUVCQUs5c2NWTkFOaisxNnM5NktXdW44UUpUM0VJZmxUZkVMM2FWaE0xcGovUXBBQWtXNTRCck51eUFBQUFBSlZSbFlXMVRjR1ZoYXlCVGVYTjBaVzF6SUVkdFlrZ0FBRTNQZldiOFJZbEtYd0hXYXZhK08raS9nZitUbzg0bE5zcWh5eFhQVVRjRkFCakJic28rMFlqd0FBQUFKRlJsWVcxVGNHVmhheUJ6ZVhOMFpXMXpJRWR0WWtnQUFGTTFQT2YxY0VzclhjckFtMU9wS1RnN2cyaHlVNlROY093TFVJaXJpbnc4QlJqQmJzbyswWWp3R2tBRFpWRFRLVndEQTdqYWVFK0pqRFQ1WUFJc1hScWxpdlpTeTR1aUJSYlB0RUZ3d0VnVXR2RHg4TEJaQ29zOEhPTDI0bllBalZ0UFRCdHJnRWF0NHVBSgo9PWtleTI9PQoKPT1rZXk9PQpWRk16VEdsalpXNXpaV1l3WkFJd1JCL1VCTWQ0MnRnQmg4TkY0dnN4K2lsTmpFZHQzazdxbXZPamRYZUVtUWFjQ0J5S0tnelZ4T2MwMDlYanIrU09BakFjR2oxV1ZwTGpiNGxGYk1DaEpsU1FXZW5zYTJhNmJXK2JoN2lCdzF1Zk5WaXFnTk41YThpN1VFS29sNmhsSmExVFViQkVpTng2T1NjbHBBOENhNDhjVk5zS050N0wvandDaVhuZFBkV3BuUW5CdUlYeXFLUXkxM2ZsNDJWUWo0Rk90V2kzTHRoYkNpODlWWEQwYXRpNTNjQlRTWHN4QXdMUndzZFBqeWFobmFsNStXaiswUFdYOE4ySlEzMFZmalFQVnAyMFk2dmc0K29lcm1vV291QUc5RDFjQzRrQVJoQnlVRmU5VnUvM2VBTVRiNUlHbnllY1k0QUF0RjJmdTR1aG5NYUFYQ3Q3UWNHN1JSVEFLQnNjSVhyVlVLM1NnS3ZLR1p6T2RGeU0=
---
# TeamSpeak 3 Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: teamspeak
namespace: teamspeak
labels:
app: teamspeak
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: teamspeak
template:
metadata:
labels:
app: teamspeak
spec:
initContainers:
- name: copy-license
image: busybox:latest
command: ['sh', '-c', 'cp /license/licensekey.dat /data/licensekey.dat']
volumeMounts:
- name: teamspeak-data
mountPath: /data
- name: license
mountPath: /license
readOnly: true
containers:
- name: teamspeak
image: teamspeak:latest
ports:
- containerPort: 9987
name: voice
protocol: UDP
- containerPort: 30033
name: filetransfer
protocol: TCP
- containerPort: 10011
name: serverquery
protocol: TCP
env:
- name: TS3SERVER_LICENSE
value: accept
- name: TS3SERVER_SERVERADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: teamspeak-credentials
key: ServerQuery-Password
volumeMounts:
- name: teamspeak-data
mountPath: /var/ts3server
resources:
requests:
memory: 128Mi
cpu: 50m
limits:
memory: 512Mi
cpu: 500m
readinessProbe:
tcpSocket:
port: 10011
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 10011
initialDelaySeconds: 60
periodSeconds: 15
volumes:
- name: teamspeak-data
persistentVolumeClaim:
claimName: teamspeak-data
- name: license
secret:
secretName: teamspeak-license
---
# TeamSpeak LoadBalancer Service
apiVersion: v1
kind: Service
metadata:
name: teamspeak
namespace: teamspeak
annotations:
metallb.universe.tf/loadBalancerIPs: 10.0.56.205
spec:
type: LoadBalancer
selector:
app: teamspeak
ports:
- port: 9987
targetPort: 9987
name: voice
protocol: UDP
- port: 30033
targetPort: 30033
name: filetransfer
protocol: TCP
- port: 10011
targetPort: 10011
name: serverquery
protocol: TCP
# TeamSpeak 3 Server
# ArgoCD managed - BlueJay Lab
---
apiVersion: v1
kind: Namespace
metadata:
name: teamspeak
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# 1Password secret sync - TeamSpeak credentials
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: teamspeak-credentials
namespace: teamspeak
spec:
itemPath: "vaults/IAmWorkin/items/TeamSpeak 3"
---
# TeamSpeak data PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: teamspeak-data
namespace: teamspeak
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
---
# TeamSpeak license key
apiVersion: v1
kind: Secret
metadata:
name: teamspeak-license
namespace: teamspeak
type: Opaque
data:
licensekey.dat: Y29tcGFueSBuYW1lIDogQW5kcmV3IFN0b2x0egphZGRyZXNzICAgICAgOiA2NjI1IDE2Mm5kIHQKemlwY29kZSAgICAgIDogNTUwNjgKY2l0eSAgICAgICAgIDogTGFrZXZpbGxlCmNvdW50cnkgICAgICA6IFVuaXRlZCBTdGF0ZXMgb2YgQW1lcmljYQpwaG9uZSAgICAgICAgOiA5NTI5OTk2NDExCmZheCAgICAgICAgICA6IApzYWxlcyBjb250YWN0OiBBbmRyZXcgU3RvbHR6IChhc3RvbHR6QGlhbXdvcmsuaW4pCnRlY2ggY29udGFjdCA6IEFuZHJldyBTdG9sdHogKGFzdG9sdHpAaWFtd29yay5pbikKCnRzIHZlcnNpb24gICA6IDMKdHlwZSAgICAgICAgIDogQWN0aXZhdGlvbiBMaWNlbnNlCnN0YXJ0IGRhdGUgICA6IFN1biBNYXIgIDggMDA6MDA6MDAgMjAyNgplbmQgZGF0ZSAgICAgOiBNb24gTWFyICA4IDAwOjAwOjAwIDIwMjcKbWF4LiB2aXJ0dWFsIHNlcnZlcnM6IDEKbWF4LiBzbG90cyAgIDogMzIKZGVzY3JpcHRpb24gIDogVGVhbVNwZWFrIDMgQUwKCgo9PWtleTI9PQpDb0VDQ3NnQkFRQ3ZiSEZUUURZL3RlclBlaWxycC9FQ1U5eENINVUzeEM5MmxZVE5hWS8wS1FBSkZ1ZUFhemJzZ0FBQUFDVlVaV0Z0VTNCbFlXc2dVM2x6ZEdWdGN5QkhiV0pJQUFCTnozMW0vRVdKU2w4QjFtcjJ2anZvdjRIL2s2UE9KVGJLb2NzVnoxRTNCUUFZd1c3S1B0R0k4QUFBQUNSVVpXRnRVM0JsWVdzZ2MzbHpkR1Z0Y3lCSGJXSklBQUFXSlVXQkErRjQzTERsMzFxS0NpOWw4WWdpQmhyOXlIWW1rbTFvWVVTMlhnSVl5cFVBR3F2SWdBWUFBQUFBUVc1a2NtVjNJRk4wYjJ4MGVnQVNJTmp2OStDVDQwTTUvQWdlY0lLcW1Gb2hyZnAzd3dSbGVSL25Ia3kvdHRKcEdDQWdBU29PVkdWaGJWTndaV0ZySURNZ1FVd1N0UUVCQUs5c2NWTkFOaisxNnM5NktXdW44UUpUM0VJZmxUZkVMM2FWaE0xcGovUXBBQWtXNTRCck51eUFBQUFBSlZSbFlXMVRjR1ZoYXlCVGVYTjBaVzF6SUVkdFlrZ0FBRTNQZldiOFJZbEtYd0hXYXZhK08raS9nZitUbzg0bE5zcWh5eFhQVVRjRkFCakJic28rMFlqd0FBQUFKRlJsWVcxVGNHVmhheUJ6ZVhOMFpXMXpJRWR0WWtnQUFGTTFQT2YxY0VzclhjckFtMU9wS1RnN2cyaHlVNlROY093TFVJaXJpbnc4QlJqQmJzbyswWWp3R2tBRFpWRFRLVndEQTdqYWVFK0pqRFQ1WUFJc1hScWxpdlpTeTR1aUJSYlB0RUZ3d0VnVXR2RHg4TEJaQ29zOEhPTDI0bllBalZ0UFRCdHJnRWF0NHVBSgo9PWtleTI9PQoKPT1rZXk9PQpWRk16VEdsalpXNXpaV1l3WkFJd1JCL1VCTWQ0MnRnQmg4TkY0dnN4K2lsTmpFZHQzazdxbXZPamRYZUVtUWFjQ0J5S0tnelZ4T2MwMDlYanIrU09BakFjR2oxV1ZwTGpiNGxGYk1DaEpsU1FXZW5zYTJhNmJXK2JoN2lCdzF1Zk5WaXFnTk41YThpN1VFS29sNmhsSmExVFViQkVpTng2T1NjbHBBOENhNDhjVk5zS050N0wvandDaVhuZFBkV3BuUW5CdUlYeXFLUXkxM2ZsNDJWUWo0Rk90V2kzTHRoYkNpODlWWEQwYXRpNTNjQlRTWHN4QXdMUndzZFBqeWFobmFsNStXaiswUFdYOE4ySlEzMFZmalFQVnAyMFk2dmc0K29lcm1vV291QUc5RDFjQzRrQVJoQnlVRmU5VnUvM2VBTVRiNUlHbnllY1k0QUF0RjJmdTR1aG5NYUFYQ3Q3UWNHN1JSVEFLQnNjSVhyVlVLM1NnS3ZLR1p6T2RGeU0=
---
# TeamSpeak 3 Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: teamspeak
namespace: teamspeak
labels:
app: teamspeak
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: teamspeak
template:
metadata:
labels:
app: teamspeak
spec:
initContainers:
- name: copy-license
image: busybox:latest
command: ['sh', '-c', 'cp /license/licensekey.dat /data/licensekey.dat']
volumeMounts:
- name: teamspeak-data
mountPath: /data
- name: license
mountPath: /license
readOnly: true
containers:
- name: teamspeak
image: teamspeak:latest
ports:
- containerPort: 9987
name: voice
protocol: UDP
- containerPort: 30033
name: filetransfer
protocol: TCP
- containerPort: 10011
name: serverquery
protocol: TCP
env:
- name: TS3SERVER_LICENSE
value: accept
- name: TS3SERVER_SERVERADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: teamspeak-credentials
key: ServerQuery-Password
volumeMounts:
- name: teamspeak-data
mountPath: /var/ts3server
resources:
requests:
memory: 128Mi
cpu: 50m
limits:
memory: 512Mi
cpu: 500m
readinessProbe:
tcpSocket:
port: 10011
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 10011
initialDelaySeconds: 60
periodSeconds: 15
volumes:
- name: teamspeak-data
persistentVolumeClaim:
claimName: teamspeak-data
- name: license
secret:
secretName: teamspeak-license
---
# TeamSpeak LoadBalancer Service
apiVersion: v1
kind: Service
metadata:
name: teamspeak
namespace: teamspeak
annotations:
metallb.universe.tf/loadBalancerIPs: 10.0.56.205
spec:
type: LoadBalancer
selector:
app: teamspeak
ports:
- port: 9987
targetPort: 9987
name: voice
protocol: UDP
- port: 30033
targetPort: 30033
name: filetransfer
protocol: TCP
- port: 10011
targetPort: 10011
name: serverquery
protocol: TCP

View File

@@ -1,349 +1,387 @@
# FlowerCore.Telephony - Blazor Server + REST API + Twilio IVR
# ArgoCD managed - BlueJay Lab
# Credentials: 1Password → OnePasswordItem CRD → K8s Secret (twilio-credentials)
# TTS: Piper on edge1 (10.0.57.15:8500)
# Public: telephony.flowercore.io via Cloudflare origin cert
---
apiVersion: v1
kind: Namespace
metadata:
name: telephony
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Cloudflare Origin Certificate for *.flowercore.io + *.iamwork.in (15-year RSA)
apiVersion: v1
kind: Secret
metadata:
name: cf-origin-flowercore-io
namespace: telephony
type: kubernetes.io/tls
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVvRENDQTRpZ0F3SUJBZ0lVSXN4c1NKV1VRL0tqZ09ldk81YnNuVi9rZVE4d0RRWUpLb1pJaHZjTkFRRUwKQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJrd0Z3WURWUVFLRXhCRGJHOTFaRVpzWVhKbExDQkpibU11TVRRdwpNZ1lEVlFRTEV5dERiRzkxWkVac1lYSmxJRTl5YVdkcGJpQlRVMHdnUTJWeWRHbG1hV05oZEdVZ1FYVjBhRzl5CmFYUjVNUll3RkFZRFZRUUhFdzFUWVc0Z1JuSmhibU5wYzJOdk1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGgKTUI0WERUSTJNRE14TURFMk16TXdNRm9YRFRReE1ETXdOakUyTXpNd01Gb3dZakVaTUJjR0ExVUVDaE1RUTJ4dgpkV1JHYkdGeVpTd2dTVzVqTGpFZE1Cc0dBMVVFQ3hNVVEyeHZkV1JHYkdGeVpTQlBjbWxuYVc0Z1EwRXhKakFrCkJnTlZCQU1USFVOc2IzVmtSbXhoY21VZ1QzSnBaMmx1SUVObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUcKOXcwQkFRRUZBQU9DQVE0QU1JSUJDZ0tDQVFFQXV0QmpkQ0xEdHdMQlZCU0Y1ZU1OMkt3ckIxTmZmRVhRMjlRRAo1aVR0dzJFcEZXNVJJSllkMjNrYUpCMU5jZXpHWlg4a0Q0cGEyWHpFZW1MVEtJNWw0MU11b3FoWjczNVE3U3RWCkVjRFFTT2ZYTkZQdFMwb0hqb0pRdGF2QjM0ZmJNR3l4Mmx0MU9HUzRNMGtLUWpBNWR6OTJQYjNyZ1RKR0JhOW4KeTZtVThncjRuUHRSdklxZ3NxdjRtMFA3dVU1YjE3NzU1Y2JLSDVoMzIxWHVjMDU4Tzl4M2JHQ0NuRUJXWDdqeApjRGhkUEs1Ri9XRjVBQnl5cFhIQ0ZxUUd4M1NVbmtCQ0ZQSmRabnMra3BHVUZWZGhud3B6NjBtNnlJSzQ0eVR4CjZqR3JOTFEyM1dOK2gwU1lCZU5vb2JBWThydkpiVlZEaGJqSVhBTWtFNGQzVll1TlhRSURBUUFCbzRJQklqQ0MKQVI0d0RnWURWUjBQQVFIL0JBUURBZ1dnTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjRApBVEFNQmdOVkhSTUJBZjhFQWpBQU1CMEdBMVVkRGdRV0JCUkt1NkJVUDZ0N2dpbFRPay9FdEdKQ3R6N3dTREFmCkJnTlZIU01FR0RBV2dCUWs2Rk5YWFh3MFFJZXA2NVRidXVFV2VQd3BwREJBQmdnckJnRUZCUWNCQVFRME1ESXcKTUFZSUt3WUJCUVVITUFHR0pHaDBkSEE2THk5dlkzTndMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5agpZVEFqQmdOVkhSRUVIREFhZ2d3cUxtbGhiWGR2Y21zdWFXNkNDbWxoYlhkdmNtc3VhVzR3T0FZRFZSMGZCREV3Ckx6QXRvQ3VnS1lZbmFIUjBjRG92TDJOeWJDNWpiRzkxWkdac1lYSmxMbU52YlM5dmNtbG5hVzVmWTJFdVkzSnMKTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFDSjMvTGNleE5pb0lWdUxoemhmbTZCeDV2SWk3T25CaHF1WUlDdwplNnArZ0prdE16ZFJQcDV0bk03dllBWmxMajVJOTByWDRuczhJc3dEbzJBN2wwYTRGZVJFclFmRklsZXQzbjIyCjUxVTZYVElCSks5c1FZT0FkU3pJUzV1OUNKSFpBUTF5WmxSd3BBR3RVWnhxL1dpcGFWUTRwNXhrcEJNMVlZSlAKNW1jQ09HcFErSnpORlpQc2daYUJncDBYL1BBZkNJRkkyZld5QWE2elBqRm0rdDVXUXIrZlBaT2VUS2VIbWVzVgo3UlZxUUdEb3Q0eTY1NklEdmdmU2ZLRnFIRW9XNDJVbDBxQ05hMS9keEJld3NIS1VWWE1ETkdiQlNVQjM4TG9YCm1OQ3hJQlVOUjR0TG1CQUxZT3hVMnZhSWRCd0xBc2YrcndnVnVjUGpCUTc2VWMwUQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQzYwR04wSXNPM0FzRlUKRklYbDR3M1lyQ3NIYTE5OFJkRGIxQVBtSk8zRFlTa1ZibEVnbGgzYmVSb2tIVTF4N01abGZ5UVBpbHJaZk1SNgpZdE1vam1YalV5NmlxRm52ZmxEdEsxVVJ3TkJJNTljMFUrMUxTZ2VPZ2xDMXE4SGZoOXN3YkxIYVczVTRaTGd6ClNRcENNRGwzUDNZOXZldUJNa1lGcjJmTHFaVHlDdmljKzFHOGlxQ3lxL2liUS91NVRsdlh2dm5seHNvZm1IZmIKVmU1elRudzczSGRzWUlLY1FGWmZ1UEZ3T0YwOHJrWDlZWGtBSExLbGNjSVdwQWJIZEpTZVFFSVU4bDFtZXo2UwprWlFWVjJHZkNuUHJTYnJJZ3JqakpQSHFNYXMwdERiZFkzNkhSSmdGNDJpaHNCanl1OGx0VlVPRnVNaGNBeVFUCmgzZFZpNDFkQWdNQkFBRUNnZ0VBTGlseXZkNmVTcEYvZUxtV2lhTVV4NUxwa2dhWHpITkxCQnNNZUpqcytLL0EKVVdlZ1crTkVUdmlLalZ5QlI5SzRocG1IYldDa2lPUDBBQUwrQnlKQ3lvekNOQmJTSEdRejlwc1R5dzZBV1ZlUwpuYjlVWGx1VmFQRktKTTRqbXNydERuYjVic25WT2lGblErTDdTalkwNlFMUlFybjBvUWp0ZFJldUdBMFlQVU90CkhSYzNsMFg2ZHJqdkJYY2prWTQwWm9ZYkRrelJnU1JWbWVOUGFIbjZPR0NtYUVUMXVyK01qYVZ2ME9lbEdIWncKVzljSEIxaHNxRzUvMWU3V0RQN0l0cjkwTmg4ay81NVhiK3lQUnhsRFd5bWtZMzIvdFBtZzdESTRKV2tRRWt3cgpIZUtwODVTcE5ta1liRnVpVFppeU8zZDZ0aXZHNHhFZW8rSzFVVFU4c1FLQmdRRFRNSEU1RDFYVC9HbGR5VHNsCllrODRVL1N0NXUrK2RIUEt1Wmw2dVB0UGgxV1lrdnFRcmdrL05YanVud2xGN0Y3b2tWOGdPeWxreTYwYTZkcXIKeXZwN1ZJdXYzekVlc2h2NjNWMlpaVkMzcXZYSzFheit3Zmx3NitCZmVuRlY5S2NENHN0dTdwOFRPWmFGN01CUgo3YXZzaXVXbWtqdmM1TlVLRmVDRTY0SnZFUUtCZ1FEaWMrbWlNLzBodDN1ajhuOXgyMDFQZFNqbEpVaUc1NjNNCnRYZlBCdDJRT0NhaVluUFNFdTdXdm5pQWRFL2xrMm91cFRWam9LYmZPbDFyQjd6UzVhc2kxdVdDZDhlUy9UWGIKdU5iRmlNMDB4L3JxalMydCtQbTd4MVhrYTB4TFNSRDNmZ0tSQldSN3pscStkYWZ1WE1qelUxRnh5dTIycGphRgpIMEl3NEpCUmpRS0JnUUNOaWhMb0Rob1V5RCtKNXJzb00vb3FJMEtDWnB0WlJzendHbkg5cVFwdFk2Ti9iVXBYCk92emhpeUh3czAvUXVEbG5uejVrNktHMmR6Y2VLWXN2eGdzWUt6S3ZmV043VWgya2hVWWM3NlVvWTREMkh6MGgKUkxtNzc2cGg4enNRUTdiSHlQRlUrTUpPYlRNdnNOdTRUUlVEcEplRGl0QnFIRWVYeWMrKzVlUjJNUUtCZ0h2UgptVHVoWlpVYitEVEtrVGkyQ20yWnlBU1RBRGNUVW9xTjVyYUNNSDk4MUZNUnRmWjFkN1pmYXhBQmlQWWtSbmkrCnlKUnk4UXM1cEg2ek9tR3VSb2JFTGJYS3ZJcjRmSXhwWXJXYmVXaVV0L09yd2dCUUZHekNMNHEzeUgyWnMvYy8KSlRRYVdMa0JPY2pPR0VaUzRXVjZkeHZiTTJNZE9zNUxLeXdDZmFhNUFvR0FIQUE1eEN0dndOZE4xeExndkZ3RApPK2lyMDl1bXMxOFBzSVpmK1ZrWGtpcHF4MWNUT0hEanpPR01yWXV0M2FFeE00Zjd2ckFHRFMyY2pwZjM0T1JxCit4Y2gwWlNaQ2FDZmlnZG9OelNkcDFLcmo0cnFKdG5ZdS9CNDlDQlVoSDBNaCtSRWswQ0hHOVE4b3FOWFk0V0wKbVVOVTZMYUkwQWtvSzNVb2tWQVJEYXM9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
---
# 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
metadata:
name: telephony-config
namespace: telephony
data:
appsettings.Production.json: |
{
"Telephony": {
"Provider": "asterisk",
"Twilio": {
"VoiceUrl": "https://telephony.flowercore.io/api/twilio/webhooks/voice/incoming",
"StatusCallbackUrl": "https://telephony.flowercore.io/api/twilio/webhooks/voice/status"
},
"Asterisk": {
"BaseUrl": "http://localhost:8088",
"Username": "flowercore",
"Password": "bluejay-asterisk-ari",
"Application": "flowercore-pbx",
"ReconnectDelaySeconds": 5,
"MaxReconnectDelaySeconds": 60
}
},
"Ari": {
"BaseUrl": "http://localhost:8088",
"Username": "flowercore",
"Password": "bluejay-asterisk-ari",
"Application": "flowercore-pbx",
"ReconnectDelaySeconds": 5,
"MaxReconnectDelaySeconds": 60
},
"Tts": {
"PiperUrl": "http://10.0.57.15:8500",
"DefaultEngine": "piper",
"SampleRate": 8000
},
"DatabaseProvider": "Sqlite",
"ConnectionStrings": {
"DefaultConnection": "Data Source=/data/telephony.db"
},
"Kestrel": {
"Endpoints": {
"Http": { "Url": "http://0.0.0.0:5100" }
}
}
}
---
# Persistent volume for SQLite database
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: telephony-data
namespace: telephony
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
---
# Telephony web application
apiVersion: apps/v1
kind: Deployment
metadata:
name: telephony-web
namespace: telephony
labels:
app: telephony-web
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: telephony-web
template:
metadata:
labels:
app: telephony-web
spec:
securityContext:
fsGroup: 1654
initContainers:
- name: fix-data-perms
image: busybox:latest
command: ["sh", "-c", "chown -R 1654:1654 /data"]
volumeMounts:
- name: telephony-data
mountPath: /data
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: asterisk
topologyKey: kubernetes.io/hostname
containers:
- name: telephony-web
image: localhost/fc-telephony-web:latest
imagePullPolicy: Never
ports:
- containerPort: 5100
name: http
env:
- name: Telephony__Twilio__AccountSid
valueFrom:
secretKeyRef:
name: twilio-credentials
key: AccountSid
optional: true
- name: Telephony__Twilio__AuthToken
valueFrom:
secretKeyRef:
name: twilio-credentials
key: AuthToken
optional: true
- name: Telephony__Twilio__DefaultFromNumber
valueFrom:
secretKeyRef:
name: twilio-credentials
key: DefaultFromNumber
optional: true
volumeMounts:
- name: telephony-config
mountPath: /app/appsettings.Production.json
subPath: appsettings.Production.json
readOnly: true
- name: telephony-data
mountPath: /data
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: "1"
livenessProbe:
httpGet:
path: /health
port: 5100
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 5100
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: telephony-config
configMap:
name: telephony-config
- name: telephony-data
persistentVolumeClaim:
claimName: telephony-data
---
# ClusterIP service
apiVersion: v1
kind: Service
metadata:
name: telephony-web
namespace: telephony
spec:
selector:
app: telephony-web
ports:
- port: 5100
targetPort: 5100
name: http
---
# Traefik IngressRoute — public via Cloudflare (primary: flowercore.io)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: telephony-web
namespace: telephony
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`telephony.flowercore.io`)
services:
- name: telephony-web
port: 5100
- kind: Rule
match: Host(`telephony.iamwork.in`)
services:
- name: telephony-web
port: 5100
tls:
secretName: cf-origin-flowercore-io
---
# NetworkPolicy: deny-all baseline + Traefik ingress + SIP/RTP ingress + DNS egress + TTS egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: telephony-netpol
namespace: telephony
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
# Allow Traefik ingress controller
- from:
- 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:
cidr: 0.0.0.0/0
ports:
- port: 5060
protocol: UDP
- port: 5060
protocol: TCP
- port: 10000
endPort: 20000
protocol: UDP
egress:
# Allow DNS resolution (CoreDNS in kube-system)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# Allow Piper TTS on edge1 (10.0.57.15:8500)
- to:
- ipBlock:
cidr: 10.0.57.15/32
ports:
- port: 8500
protocol: TCP
# Allow Twilio API outbound (HTTPS)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- port: 443
protocol: TCP
# Allow SIP/RTP responses (Asterisk → phones and Twilio)
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 5060
protocol: UDP
- port: 5060
protocol: TCP
- port: 10000
endPort: 20000
protocol: UDP
# Allow 1Password Connect for secret sync
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: onepassword-system
---
# TLS Certificate for internal hostname via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: telephony-internal-tls
namespace: telephony
spec:
secretName: telephony-internal-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- telephony.iamworkin.lan
---
# Traefik IngressRoute — internal LAN access
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: telephony-web-internal
namespace: telephony
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`telephony.iamworkin.lan`)
services:
- name: telephony-web
port: 5100
tls:
secretName: telephony-internal-tls
# 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":"..."}
# Public: telephony.flowercore.io via Cloudflare origin cert
---
apiVersion: v1
kind: Namespace
metadata:
name: telephony
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Cloudflare Origin Certificate for *.flowercore.io + *.iamwork.in (15-year RSA)
apiVersion: v1
kind: Secret
metadata:
name: cf-origin-flowercore-io
namespace: telephony
type: kubernetes.io/tls
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVvRENDQTRpZ0F3SUJBZ0lVSXN4c1NKV1VRL0tqZ09ldk81YnNuVi9rZVE4d0RRWUpLb1pJaHZjTkFRRUwKQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJrd0Z3WURWUVFLRXhCRGJHOTFaRVpzWVhKbExDQkpibU11TVRRdwpNZ1lEVlFRTEV5dERiRzkxWkVac1lYSmxJRTl5YVdkcGJpQlRVMHdnUTJWeWRHbG1hV05oZEdVZ1FYVjBhRzl5CmFYUjVNUll3RkFZRFZRUUhFdzFUWVc0Z1JuSmhibU5wYzJOdk1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGgKTUI0WERUSTJNRE14TURFMk16TXdNRm9YRFRReE1ETXdOakUyTXpNd01Gb3dZakVaTUJjR0ExVUVDaE1RUTJ4dgpkV1JHYkdGeVpTd2dTVzVqTGpFZE1Cc0dBMVVFQ3hNVVEyeHZkV1JHYkdGeVpTQlBjbWxuYVc0Z1EwRXhKakFrCkJnTlZCQU1USFVOc2IzVmtSbXhoY21VZ1QzSnBaMmx1SUVObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUcKOXcwQkFRRUZBQU9DQVE0QU1JSUJDZ0tDQVFFQXV0QmpkQ0xEdHdMQlZCU0Y1ZU1OMkt3ckIxTmZmRVhRMjlRRAo1aVR0dzJFcEZXNVJJSllkMjNrYUpCMU5jZXpHWlg4a0Q0cGEyWHpFZW1MVEtJNWw0MU11b3FoWjczNVE3U3RWCkVjRFFTT2ZYTkZQdFMwb0hqb0pRdGF2QjM0ZmJNR3l4Mmx0MU9HUzRNMGtLUWpBNWR6OTJQYjNyZ1RKR0JhOW4KeTZtVThncjRuUHRSdklxZ3NxdjRtMFA3dVU1YjE3NzU1Y2JLSDVoMzIxWHVjMDU4Tzl4M2JHQ0NuRUJXWDdqeApjRGhkUEs1Ri9XRjVBQnl5cFhIQ0ZxUUd4M1NVbmtCQ0ZQSmRabnMra3BHVUZWZGhud3B6NjBtNnlJSzQ0eVR4CjZqR3JOTFEyM1dOK2gwU1lCZU5vb2JBWThydkpiVlZEaGJqSVhBTWtFNGQzVll1TlhRSURBUUFCbzRJQklqQ0MKQVI0d0RnWURWUjBQQVFIL0JBUURBZ1dnTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjRApBVEFNQmdOVkhSTUJBZjhFQWpBQU1CMEdBMVVkRGdRV0JCUkt1NkJVUDZ0N2dpbFRPay9FdEdKQ3R6N3dTREFmCkJnTlZIU01FR0RBV2dCUWs2Rk5YWFh3MFFJZXA2NVRidXVFV2VQd3BwREJBQmdnckJnRUZCUWNCQVFRME1ESXcKTUFZSUt3WUJCUVVITUFHR0pHaDBkSEE2THk5dlkzTndMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5agpZVEFqQmdOVkhSRUVIREFhZ2d3cUxtbGhiWGR2Y21zdWFXNkNDbWxoYlhkdmNtc3VhVzR3T0FZRFZSMGZCREV3Ckx6QXRvQ3VnS1lZbmFIUjBjRG92TDJOeWJDNWpiRzkxWkdac1lYSmxMbU52YlM5dmNtbG5hVzVmWTJFdVkzSnMKTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFDSjMvTGNleE5pb0lWdUxoemhmbTZCeDV2SWk3T25CaHF1WUlDdwplNnArZ0prdE16ZFJQcDV0bk03dllBWmxMajVJOTByWDRuczhJc3dEbzJBN2wwYTRGZVJFclFmRklsZXQzbjIyCjUxVTZYVElCSks5c1FZT0FkU3pJUzV1OUNKSFpBUTF5WmxSd3BBR3RVWnhxL1dpcGFWUTRwNXhrcEJNMVlZSlAKNW1jQ09HcFErSnpORlpQc2daYUJncDBYL1BBZkNJRkkyZld5QWE2elBqRm0rdDVXUXIrZlBaT2VUS2VIbWVzVgo3UlZxUUdEb3Q0eTY1NklEdmdmU2ZLRnFIRW9XNDJVbDBxQ05hMS9keEJld3NIS1VWWE1ETkdiQlNVQjM4TG9YCm1OQ3hJQlVOUjR0TG1CQUxZT3hVMnZhSWRCd0xBc2YrcndnVnVjUGpCUTc2VWMwUQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQzYwR04wSXNPM0FzRlUKRklYbDR3M1lyQ3NIYTE5OFJkRGIxQVBtSk8zRFlTa1ZibEVnbGgzYmVSb2tIVTF4N01abGZ5UVBpbHJaZk1SNgpZdE1vam1YalV5NmlxRm52ZmxEdEsxVVJ3TkJJNTljMFUrMUxTZ2VPZ2xDMXE4SGZoOXN3YkxIYVczVTRaTGd6ClNRcENNRGwzUDNZOXZldUJNa1lGcjJmTHFaVHlDdmljKzFHOGlxQ3lxL2liUS91NVRsdlh2dm5seHNvZm1IZmIKVmU1elRudzczSGRzWUlLY1FGWmZ1UEZ3T0YwOHJrWDlZWGtBSExLbGNjSVdwQWJIZEpTZVFFSVU4bDFtZXo2UwprWlFWVjJHZkNuUHJTYnJJZ3JqakpQSHFNYXMwdERiZFkzNkhSSmdGNDJpaHNCanl1OGx0VlVPRnVNaGNBeVFUCmgzZFZpNDFkQWdNQkFBRUNnZ0VBTGlseXZkNmVTcEYvZUxtV2lhTVV4NUxwa2dhWHpITkxCQnNNZUpqcytLL0EKVVdlZ1crTkVUdmlLalZ5QlI5SzRocG1IYldDa2lPUDBBQUwrQnlKQ3lvekNOQmJTSEdRejlwc1R5dzZBV1ZlUwpuYjlVWGx1VmFQRktKTTRqbXNydERuYjVic25WT2lGblErTDdTalkwNlFMUlFybjBvUWp0ZFJldUdBMFlQVU90CkhSYzNsMFg2ZHJqdkJYY2prWTQwWm9ZYkRrelJnU1JWbWVOUGFIbjZPR0NtYUVUMXVyK01qYVZ2ME9lbEdIWncKVzljSEIxaHNxRzUvMWU3V0RQN0l0cjkwTmg4ay81NVhiK3lQUnhsRFd5bWtZMzIvdFBtZzdESTRKV2tRRWt3cgpIZUtwODVTcE5ta1liRnVpVFppeU8zZDZ0aXZHNHhFZW8rSzFVVFU4c1FLQmdRRFRNSEU1RDFYVC9HbGR5VHNsCllrODRVL1N0NXUrK2RIUEt1Wmw2dVB0UGgxV1lrdnFRcmdrL05YanVud2xGN0Y3b2tWOGdPeWxreTYwYTZkcXIKeXZwN1ZJdXYzekVlc2h2NjNWMlpaVkMzcXZYSzFheit3Zmx3NitCZmVuRlY5S2NENHN0dTdwOFRPWmFGN01CUgo3YXZzaXVXbWtqdmM1TlVLRmVDRTY0SnZFUUtCZ1FEaWMrbWlNLzBodDN1ajhuOXgyMDFQZFNqbEpVaUc1NjNNCnRYZlBCdDJRT0NhaVluUFNFdTdXdm5pQWRFL2xrMm91cFRWam9LYmZPbDFyQjd6UzVhc2kxdVdDZDhlUy9UWGIKdU5iRmlNMDB4L3JxalMydCtQbTd4MVhrYTB4TFNSRDNmZ0tSQldSN3pscStkYWZ1WE1qelUxRnh5dTIycGphRgpIMEl3NEpCUmpRS0JnUUNOaWhMb0Rob1V5RCtKNXJzb00vb3FJMEtDWnB0WlJzendHbkg5cVFwdFk2Ti9iVXBYCk92emhpeUh3czAvUXVEbG5uejVrNktHMmR6Y2VLWXN2eGdzWUt6S3ZmV043VWgya2hVWWM3NlVvWTREMkh6MGgKUkxtNzc2cGg4enNRUTdiSHlQRlUrTUpPYlRNdnNOdTRUUlVEcEplRGl0QnFIRWVYeWMrKzVlUjJNUUtCZ0h2UgptVHVoWlpVYitEVEtrVGkyQ20yWnlBU1RBRGNUVW9xTjVyYUNNSDk4MUZNUnRmWjFkN1pmYXhBQmlQWWtSbmkrCnlKUnk4UXM1cEg2ek9tR3VSb2JFTGJYS3ZJcjRmSXhwWXJXYmVXaVV0L09yd2dCUUZHekNMNHEzeUgyWnMvYy8KSlRRYVdMa0JPY2pPR0VaUzRXVjZkeHZiTTJNZE9zNUxLeXdDZmFhNUFvR0FIQUE1eEN0dndOZE4xeExndkZ3RApPK2lyMDl1bXMxOFBzSVpmK1ZrWGtpcHF4MWNUT0hEanpPR01yWXV0M2FFeE00Zjd2ckFHRFMyY2pwZjM0T1JxCit4Y2gwWlNaQ2FDZmlnZG9OelNkcDFLcmo0cnFKdG5ZdS9CNDlDQlVoSDBNaCtSRWswQ0hHOVE4b3FOWFk0V0wKbVVOVTZMYUkwQWtvSzNVb2tWQVJEYXM9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
---
# 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
metadata:
name: telephony-config
namespace: telephony
data:
appsettings.Production.json: |
{
"Telephony": {
"Provider": "asterisk",
"Twilio": {
"VoiceUrl": "https://telephony.flowercore.io/api/twilio/webhooks/voice/incoming",
"StatusCallbackUrl": "https://telephony.flowercore.io/api/twilio/webhooks/voice/status"
},
"Asterisk": {
"BaseUrl": "http://10.0.56.12:8088",
"Username": "flowercore",
"Password": "bluejay-asterisk-ari",
"Application": "flowercore-pbx",
"ReconnectDelaySeconds": 5,
"MaxReconnectDelaySeconds": 60
}
},
"Ari": {
"BaseUrl": "http://10.0.56.12:8088",
"Username": "flowercore",
"Password": "bluejay-asterisk-ari",
"Application": "flowercore-pbx",
"ReconnectDelaySeconds": 5,
"MaxReconnectDelaySeconds": 60
},
"Sip": {
"Domain": "10.0.56.207",
"Port": 5060,
"Transport": "udp"
},
"Tts": {
"PiperUrl": "http://10.0.57.17:8500",
"DefaultEngine": "piper",
"SampleRate": 8000
},
"DatabaseProvider": "Sqlite",
"ConnectionStrings": {
"DefaultConnection": "Data Source=/data/telephony.db"
},
"Kestrel": {
"Endpoints": {
"Http": { "Url": "http://0.0.0.0:5100" }
}
}
}
---
# Persistent volume for SQLite database
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: telephony-data
namespace: telephony
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
---
# Telephony web application
apiVersion: apps/v1
kind: Deployment
metadata:
name: telephony-web
namespace: telephony
labels:
app: telephony-web
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: telephony-web
template:
metadata:
labels:
app: telephony-web
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
fsGroup: 1654
nodeSelector:
kubernetes.io/hostname: rke2-agent1
initContainers:
- name: fix-data-perms
image: busybox:latest
# Also chown /shared-tts (hostPath /tmp/tts-audio) so the non-root
# app user (uid 1654) can write Piper .sln16 files that Asterisk
# reads at /var/lib/asterisk/sounds/tts. World-readable (755) is
# fine — Asterisk runs as a different uid in the other pod.
command: ["sh", "-c", "chown -R 1654:1654 /data && chown 1654:1654 /shared-tts && chmod 0755 /shared-tts"]
volumeMounts:
- name: telephony-data
mountPath: /data
- name: shared-tts
mountPath: /shared-tts
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: asterisk
topologyKey: kubernetes.io/hostname
containers:
- name: telephony-web
image: localhost/fc-telephony-web:v202604252156
imagePullPolicy: Never
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
ports:
- containerPort: 5100
name: http
env:
- name: Telephony__Twilio__AccountSid
valueFrom:
secretKeyRef:
name: twilio-credentials
key: AccountSid
optional: true
- name: Telephony__Twilio__AuthToken
valueFrom:
secretKeyRef:
name: twilio-credentials
key: AuthToken
optional: true
- name: Telephony__Twilio__DefaultFromNumber
valueFrom:
secretKeyRef:
name: twilio-credentials
key: DefaultFromNumber
optional: true
volumeMounts:
- name: telephony-config
mountPath: /app/appsettings.Production.json
subPath: appsettings.Production.json
readOnly: true
- name: telephony-data
mountPath: /data
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /app/logs
# Shared TTS audio — we write Piper .sln16 output here; Asterisk
# pod reads the same hostPath at /var/lib/asterisk/sounds/tts and
# plays via `sound:tts/<name>`. Both pods are pinned to rke2-agent1.
- name: shared-tts
mountPath: /shared-tts
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: "1"
livenessProbe:
httpGet:
path: /health
port: 5100
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 5100
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: telephony-config
configMap:
name: telephony-config
- name: telephony-data
persistentVolumeClaim:
claimName: telephony-data
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
- name: shared-tts
hostPath:
path: /tmp/tts-audio
type: DirectoryOrCreate
---
# ClusterIP service
apiVersion: v1
kind: Service
metadata:
name: telephony-web
namespace: telephony
spec:
selector:
app: telephony-web
ports:
- port: 5100
targetPort: 5100
name: http
---
# Traefik IngressRoute — public via Cloudflare (primary: flowercore.io)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: telephony-web
namespace: telephony
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`telephony.flowercore.io`)
services:
- name: telephony-web
port: 5100
- kind: Rule
match: Host(`telephony.iamwork.in`)
services:
- name: telephony-web
port: 5100
tls:
secretName: cf-origin-flowercore-io
---
# NetworkPolicy: deny-all baseline + Traefik ingress + SIP/RTP ingress + DNS egress + TTS egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: telephony-netpol
namespace: telephony
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
# Allow Traefik ingress controller
- from:
- 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:
cidr: 0.0.0.0/0
ports:
- port: 5060
protocol: UDP
- port: 5060
protocol: TCP
- port: 10000
endPort: 20000
protocol: UDP
egress:
# Allow DNS resolution (CoreDNS in kube-system)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# Allow Piper TTS on edge1 (10.0.57.17:8500)
- to:
- ipBlock:
cidr: 10.0.57.17/32
ports:
- port: 8500
protocol: TCP
# Allow Twilio API outbound (HTTPS)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- port: 443
protocol: TCP
# Allow SIP/RTP responses (Asterisk → phones and Twilio)
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 5060
protocol: UDP
- port: 5060
protocol: TCP
- port: 10000
endPort: 20000
protocol: UDP
# Allow 1Password Connect for secret sync
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: onepassword-system
---
# TLS Certificate for internal hostname via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: telephony-internal-tls
namespace: telephony
spec:
secretName: telephony-internal-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- telephony.iamworkin.lan
---
# Traefik IngressRoute — internal LAN access
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: telephony-web-internal
namespace: telephony
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`telephony.iamworkin.lan`)
services:
- name: telephony-web
port: 5100
tls:
secretName: telephony-internal-tls

View File

@@ -1,127 +1,127 @@
# Twilio Voice Bridge - Traefik ingress to edge1
# ArgoCD managed - BlueJay Lab
# Routes voice.bluejay.dev (TwiML) and voice-ws.bluejay.dev (WebSocket)
# to edge1 Pi5 at 10.0.57.15 (PROD VLAN)
---
apiVersion: v1
kind: Namespace
metadata:
name: voice
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Cloudflare origin cert for *.bluejay.dev
apiVersion: v1
kind: Secret
metadata:
name: cf-origin-bluejay-dev
namespace: voice
type: kubernetes.io/tls
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVvakNDQTRxZ0F3SUJBZ0lVTkxnemZ4UVRzMElyWWRaZUZKUGN5TjRyNmFjd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJrd0Z3WURWUVFLRXhCRGJHOTFaRVpzWVhKbExDQkpibU11TVRRdwpNZ1lEVlFRTEV5dERiRzkxWkVac1lYSmxJRTl5YVdkcGJpQlRVMHdnUTJWeWRHbG1hV05oZEdVZ1FYVjBhRzl5CmFYUjVNUll3RkFZRFZRUUhFdzFUWVc0Z1JuSmhibU5wYzJOdk1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGgKTUI0WERUSTJNRE14TURBMU1USXdNRm9YRFRReE1ETXdOakExTVRJd01Gb3dZakVaTUJjR0ExVUVDaE1RUTJ4dgpkV1JHYkdGeVpTd2dTVzVqTGpFZE1Cc0dBMVVFQ3hNVVEyeHZkV1JHYkdGeVpTQlBjbWxuYVc0Z1EwRXhKakFrCkJnTlZCQU1USFVOc2IzVmtSbXhoY21VZ1QzSnBaMmx1SUVObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUcKOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXlPODBJQ3dPV0RWTEpQNm9RenI3aXVENmtWMGNWZ01VbUx5VApKVnVYUlhZSEY3M2ZrM2pPbytCQVE1M2pmbERHUFVYc0UvNlV6VDRoUDVYTlVLaUNXaitvYy84eE1BSWsxcWwrClZnbFI1MDBUQ0FtZDliazNZVkxiZjBSejVMMUQ0WGJmOEVzamhOUVV2Z3Y0dTZoQzdnRmdrVGplc1dIZjg0K04KNERETDdmTjFQZHR4RVBiVWZrbGN1MUZSdXdlMk9QNkFEMlJvdkphNWZwODRHcVY2TDAzdjY2RjFtMnBST1VmRwpFdWpRNG4zSms2cUx5NHZTTENzOGJlOGRBRW5QcDgyZ2NRZk9mUVlIS2JTUWhiQnMwK01vK3lkTHpHSzFRdklRCnVPcDZRT1BtM0lac09Eb0VCdG5kMTh2amx6Y1JSdE94cjNzaFovdmNWY0o0YUJNZDVRSURBUUFCbzRJQkpEQ0MKQVNBd0RnWURWUjBQQVFIL0JBUURBZ1dnTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjRApBVEFNQmdOVkhSTUJBZjhFQWpBQU1CMEdBMVVkRGdRV0JCVHgyYmFCUGlvWjZUd2U3THhnaTViUS82cUFkakFmCkJnTlZIU01FR0RBV2dCUWs2Rk5YWFh3MFFJZXA2NVRidXVFV2VQd3BwREJBQmdnckJnRUZCUWNCQVFRME1ESXcKTUFZSUt3WUJCUVVITUFHR0pHaDBkSEE2THk5dlkzTndMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5agpZVEFsQmdOVkhSRUVIakFjZ2cwcUxtSnNkV1ZxWVhrdVpHVjJnZ3RpYkhWbGFtRjVMbVJsZGpBNEJnTlZIUjhFCk1UQXZNQzJnSzZBcGhpZG9kSFJ3T2k4dlkzSnNMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5allTNWoKY213d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFDS0ZVRXhYbFB5KzlPRytJc1VWN1NXS09Udkk0b2JrUkd6bwpOckhudWZ3dGtxa0dzUGErUU5LbUk1UVNaYTVMS1YybWZJdWsrNE12U1FvZklmbUFUb1JEWEdQK041aWxEbWRSCk5rS2ttSUpZL242UzM3MGdZN0JQMjFwNjJKUnZkVUU5ZmV5RU1iMUdNbGNINjN6MUQxMzZZOWlvQ1FnYWNFZVUKOFZSVWFPZkJvby9sVzlYbXA1ZDZzcTBic2tybUhRN1ZSTjUxZCtsL0RvY2lkU2xZcHQxbXljSUN3c1F4U0dpMApYN1pMTXhHdCtDVG9jcFRFbkdrQ2t0NnhrKzVJUElXaHYvVnZuTnlQNUwxM0ZRN1d4QzFsaUIwcVdKMkEwcWpoCkR2cmxPNUpsclNWWEtNc0hwSWRFN3pJZ29JUUc2cnU1N1V4UjJyeko0d0VrSXcrd1ZyMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRREk3elFnTEE1WU5Vc2sKL3FoRE92dUs0UHFSWFJ4V0F4U1l2Sk1sVzVkRmRnY1h2ZCtUZU02ajRFQkRuZU4rVU1ZOVJld1QvcFROUGlFLwpsYzFRcUlKYVA2aHovekV3QWlUV3FYNVdDVkhuVFJNSUNaMzF1VGRoVXR0L1JIUGt2VVBoZHQvd1N5T0UxQlMrCkMvaTdxRUx1QVdDUk9ONnhZZC96ajQzZ01NdnQ4M1U5MjNFUTl0UitTVnk3VVZHN0I3WTQvb0FQWkdpOGxybCsKbnpnYXBYb3ZUZS9yb1hXYmFsRTVSOFlTNk5EaWZjbVRxb3ZMaTlJc0t6eHQ3eDBBU2MrbnphQnhCODU5QmdjcAp0SkNGc0d6VDR5ajdKMHZNWXJWQzhoQzQ2bnBBNCtiY2htdzRPZ1FHMmQzWHkrT1hOeEZHMDdHdmV5Rm4rOXhWCnduaG9FeDNsQWdNQkFBRUNnZ0VBRFpWeU9peVFTYkZNbzdNZGovSDRXR0t1UGM2RUlHSno3WUZ1Rnl2eWRZMHQKbkpMRy94Sy9NWC96Q0Q4dnhuWFNlUWoxbFVKMEw4M2Y5SXI5aHRMbGdSRmxvM1hnanVUT05iN2VuaFZpTnBkVQp6b25MNW5VL2c3SlV5VzFJd25GekdkWnQvREl3TkFZY1l0NnZVWXhsL2U0VTU2eG5EYW5XdUlIL2J1VU5uRWZtCjZNcnlIblhseDA0TzZzOElLSCtXNUxDam1ma1Jac0VveE9Damt6T2hucmdCTk4xSWxWSWhhMDhLOXBxRk5qM0wKaWd4MVNYZDhqaHNZUHJxaDNkak0rVUNKSitKMURuMjhaUmoyZUtqWDMvZmFDbGFpOEFzUVBtQTJScjZYZ3kzeApaR090dzRPL2Nxb1hnOUFDU05NanRNK3VtaXM3QzNncWp6VUZ4MWwvNFFLQmdRRFNvSGVDY1dXSUJQV3grR01hCnBSMENHejQ0eDNPRUhnNWYza2RvK3BDK0I3RnYxREV5bktqUUY4VFBYaWJXVGZVL1pBRG5DVmIvU1Z2L1QyNnkKT2NlaW44UGRqdXkraE05ZUxjWXU4QVZiK0VKOHFMUUsrZE5QZm1nYXZJMWR2V3U2Tm8wSTNSSzZWUkpiQXEyWgpLcjdsVEloMjdZMDRMajlaaUIxU01YZ0J0UUtCZ1FEME9EbEVCNHRHa1l2dTVLMkFoaDBmdnlIaUVoUTd5dWUzCmZXdG1VYnRxV0ZlM1R5UG9JTWJWMTM3MkNIdlh6MFNEMXRwYzJxa3hxN3c4d1BYM3RJZGk5NWxMbVMzZm8zWFoKdTNTYVo4enozTlAwR3JXK1Y0c3hHb1VnbWgwL2lzOXJoaWxXZGF5bU5SWEQ5U0MyUU5IdTU5NDY2UjFkczNnbgpiZlJlUkw4SmNRS0JnREptVjNLTk0rQmlYM0Jnb1VaRThEWUswczYvV3pMb0JrU0dhY3dDK1JPZnY2T2t3TWo5Cmw1K0RzSUoyWXhDd3d0aVNVMnoxWFMzbEhmQnZ6MnN5VEVUcnVmQ1FQTEl5RVhUVnV6Q01HcHd4UWFlV3JzNVoKaldqZU5JY0JTMHA5QXdRaC9ZbDdiUG5OVllFVm1QaW5zOW9tZ0JrRkt0K2dvV1FKSUFzRTcxUnBBb0dCQUxNSgphTW43c2RuNUo1bnA0VnhBZGFkcGFvQ2VlbURmUG9KaEd0UTNCT3RRZW5Xek9nS1p6TXJHSVpoaTNjOTNicVlzClk0Y0E4bHFzcU9IdElDVUpIdHVwNHFMdVdCZ0VjSWcvaVpzTWo4OFRTL3MvZlk5ZUJIZnFGa0N4V3RIVGhINHkKSzZucnVMZGNZV2w0RWhRcWJ2enkxUk5oQkp0Rno4Y3dMNTdRVFRDeEFvR0FjZFFkbzk3bzZmS0dQa3kvREpnNApoTGlLbHVkTjF3bmJaTHFVM0UwNzBwVDhJQkx3TFNpTUNpYXRXWWtScHFpdUQyMSsya1p4SE1NdnNoZWJXQmFmCmVqWU9jcHVvQ3VWdWc1K1dlbmc0cmUxSTl2c2czUE5WYkdrNW1xNmZDbndsbGFnMkEvSVBVRFFOaUpCVGM1WUQKc25udzhVTjNBTFBMSTlPVVd1eXJUckk9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
---
# Service (no selector) pointing to external edge1
apiVersion: v1
kind: Service
metadata:
name: voice-bridge
namespace: voice
spec:
ports:
- name: twiml
port: 8766
targetPort: 8766
protocol: TCP
- name: websocket
port: 8765
targetPort: 8765
protocol: TCP
---
# Manual Endpoints for edge1 (outside K8s)
apiVersion: v1
kind: Endpoints
metadata:
name: voice-bridge
namespace: voice
subsets:
- addresses:
- ip: 10.0.57.15
ports:
- name: twiml
port: 8766
protocol: TCP
- name: websocket
port: 8765
protocol: TCP
---
# TwiML webhook: voice.bluejay.dev -> edge1:8766
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: voice-twiml
namespace: voice
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`voice.bluejay.dev`)
services:
- name: voice-bridge
port: 8766
tls:
secretName: cf-origin-bluejay-dev
---
# WebSocket media stream: voice-ws.bluejay.dev -> edge1:8765
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: voice-ws
namespace: voice
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`voice-ws.bluejay.dev`)
services:
- name: voice-bridge
port: 8765
tls:
secretName: cf-origin-bluejay-dev
---
# NetworkPolicy: allow Traefik ingress only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: voice-netpol
namespace: voice
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
egress:
- to:
- ipBlock:
cidr: 10.0.57.15/32
ports:
- port: 8765
protocol: TCP
- port: 8766
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# Twilio Voice Bridge - Traefik ingress to edge1
# ArgoCD managed - BlueJay Lab
# Routes voice.bluejay.dev (TwiML) and voice-ws.bluejay.dev (WebSocket)
# to edge1 Pi5 at 10.0.57.15 (PROD VLAN)
---
apiVersion: v1
kind: Namespace
metadata:
name: voice
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# Cloudflare origin cert for *.bluejay.dev
apiVersion: v1
kind: Secret
metadata:
name: cf-origin-bluejay-dev
namespace: voice
type: kubernetes.io/tls
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVvakNDQTRxZ0F3SUJBZ0lVTkxnemZ4UVRzMElyWWRaZUZKUGN5TjRyNmFjd0RRWUpLb1pJaHZjTkFRRUwKQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJrd0Z3WURWUVFLRXhCRGJHOTFaRVpzWVhKbExDQkpibU11TVRRdwpNZ1lEVlFRTEV5dERiRzkxWkVac1lYSmxJRTl5YVdkcGJpQlRVMHdnUTJWeWRHbG1hV05oZEdVZ1FYVjBhRzl5CmFYUjVNUll3RkFZRFZRUUhFdzFUWVc0Z1JuSmhibU5wYzJOdk1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGgKTUI0WERUSTJNRE14TURBMU1USXdNRm9YRFRReE1ETXdOakExTVRJd01Gb3dZakVaTUJjR0ExVUVDaE1RUTJ4dgpkV1JHYkdGeVpTd2dTVzVqTGpFZE1Cc0dBMVVFQ3hNVVEyeHZkV1JHYkdGeVpTQlBjbWxuYVc0Z1EwRXhKakFrCkJnTlZCQU1USFVOc2IzVmtSbXhoY21VZ1QzSnBaMmx1SUVObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUcKOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXlPODBJQ3dPV0RWTEpQNm9RenI3aXVENmtWMGNWZ01VbUx5VApKVnVYUlhZSEY3M2ZrM2pPbytCQVE1M2pmbERHUFVYc0UvNlV6VDRoUDVYTlVLaUNXaitvYy84eE1BSWsxcWwrClZnbFI1MDBUQ0FtZDliazNZVkxiZjBSejVMMUQ0WGJmOEVzamhOUVV2Z3Y0dTZoQzdnRmdrVGplc1dIZjg0K04KNERETDdmTjFQZHR4RVBiVWZrbGN1MUZSdXdlMk9QNkFEMlJvdkphNWZwODRHcVY2TDAzdjY2RjFtMnBST1VmRwpFdWpRNG4zSms2cUx5NHZTTENzOGJlOGRBRW5QcDgyZ2NRZk9mUVlIS2JTUWhiQnMwK01vK3lkTHpHSzFRdklRCnVPcDZRT1BtM0lac09Eb0VCdG5kMTh2amx6Y1JSdE94cjNzaFovdmNWY0o0YUJNZDVRSURBUUFCbzRJQkpEQ0MKQVNBd0RnWURWUjBQQVFIL0JBUURBZ1dnTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjRApBVEFNQmdOVkhSTUJBZjhFQWpBQU1CMEdBMVVkRGdRV0JCVHgyYmFCUGlvWjZUd2U3THhnaTViUS82cUFkakFmCkJnTlZIU01FR0RBV2dCUWs2Rk5YWFh3MFFJZXA2NVRidXVFV2VQd3BwREJBQmdnckJnRUZCUWNCQVFRME1ESXcKTUFZSUt3WUJCUVVITUFHR0pHaDBkSEE2THk5dlkzTndMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5agpZVEFsQmdOVkhSRUVIakFjZ2cwcUxtSnNkV1ZxWVhrdVpHVjJnZ3RpYkhWbGFtRjVMbVJsZGpBNEJnTlZIUjhFCk1UQXZNQzJnSzZBcGhpZG9kSFJ3T2k4dlkzSnNMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5allTNWoKY213d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFDS0ZVRXhYbFB5KzlPRytJc1VWN1NXS09Udkk0b2JrUkd6bwpOckhudWZ3dGtxa0dzUGErUU5LbUk1UVNaYTVMS1YybWZJdWsrNE12U1FvZklmbUFUb1JEWEdQK041aWxEbWRSCk5rS2ttSUpZL242UzM3MGdZN0JQMjFwNjJKUnZkVUU5ZmV5RU1iMUdNbGNINjN6MUQxMzZZOWlvQ1FnYWNFZVUKOFZSVWFPZkJvby9sVzlYbXA1ZDZzcTBic2tybUhRN1ZSTjUxZCtsL0RvY2lkU2xZcHQxbXljSUN3c1F4U0dpMApYN1pMTXhHdCtDVG9jcFRFbkdrQ2t0NnhrKzVJUElXaHYvVnZuTnlQNUwxM0ZRN1d4QzFsaUIwcVdKMkEwcWpoCkR2cmxPNUpsclNWWEtNc0hwSWRFN3pJZ29JUUc2cnU1N1V4UjJyeko0d0VrSXcrd1ZyMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRREk3elFnTEE1WU5Vc2sKL3FoRE92dUs0UHFSWFJ4V0F4U1l2Sk1sVzVkRmRnY1h2ZCtUZU02ajRFQkRuZU4rVU1ZOVJld1QvcFROUGlFLwpsYzFRcUlKYVA2aHovekV3QWlUV3FYNVdDVkhuVFJNSUNaMzF1VGRoVXR0L1JIUGt2VVBoZHQvd1N5T0UxQlMrCkMvaTdxRUx1QVdDUk9ONnhZZC96ajQzZ01NdnQ4M1U5MjNFUTl0UitTVnk3VVZHN0I3WTQvb0FQWkdpOGxybCsKbnpnYXBYb3ZUZS9yb1hXYmFsRTVSOFlTNk5EaWZjbVRxb3ZMaTlJc0t6eHQ3eDBBU2MrbnphQnhCODU5QmdjcAp0SkNGc0d6VDR5ajdKMHZNWXJWQzhoQzQ2bnBBNCtiY2htdzRPZ1FHMmQzWHkrT1hOeEZHMDdHdmV5Rm4rOXhWCnduaG9FeDNsQWdNQkFBRUNnZ0VBRFpWeU9peVFTYkZNbzdNZGovSDRXR0t1UGM2RUlHSno3WUZ1Rnl2eWRZMHQKbkpMRy94Sy9NWC96Q0Q4dnhuWFNlUWoxbFVKMEw4M2Y5SXI5aHRMbGdSRmxvM1hnanVUT05iN2VuaFZpTnBkVQp6b25MNW5VL2c3SlV5VzFJd25GekdkWnQvREl3TkFZY1l0NnZVWXhsL2U0VTU2eG5EYW5XdUlIL2J1VU5uRWZtCjZNcnlIblhseDA0TzZzOElLSCtXNUxDam1ma1Jac0VveE9Damt6T2hucmdCTk4xSWxWSWhhMDhLOXBxRk5qM0wKaWd4MVNYZDhqaHNZUHJxaDNkak0rVUNKSitKMURuMjhaUmoyZUtqWDMvZmFDbGFpOEFzUVBtQTJScjZYZ3kzeApaR090dzRPL2Nxb1hnOUFDU05NanRNK3VtaXM3QzNncWp6VUZ4MWwvNFFLQmdRRFNvSGVDY1dXSUJQV3grR01hCnBSMENHejQ0eDNPRUhnNWYza2RvK3BDK0I3RnYxREV5bktqUUY4VFBYaWJXVGZVL1pBRG5DVmIvU1Z2L1QyNnkKT2NlaW44UGRqdXkraE05ZUxjWXU4QVZiK0VKOHFMUUsrZE5QZm1nYXZJMWR2V3U2Tm8wSTNSSzZWUkpiQXEyWgpLcjdsVEloMjdZMDRMajlaaUIxU01YZ0J0UUtCZ1FEME9EbEVCNHRHa1l2dTVLMkFoaDBmdnlIaUVoUTd5dWUzCmZXdG1VYnRxV0ZlM1R5UG9JTWJWMTM3MkNIdlh6MFNEMXRwYzJxa3hxN3c4d1BYM3RJZGk5NWxMbVMzZm8zWFoKdTNTYVo4enozTlAwR3JXK1Y0c3hHb1VnbWgwL2lzOXJoaWxXZGF5bU5SWEQ5U0MyUU5IdTU5NDY2UjFkczNnbgpiZlJlUkw4SmNRS0JnREptVjNLTk0rQmlYM0Jnb1VaRThEWUswczYvV3pMb0JrU0dhY3dDK1JPZnY2T2t3TWo5Cmw1K0RzSUoyWXhDd3d0aVNVMnoxWFMzbEhmQnZ6MnN5VEVUcnVmQ1FQTEl5RVhUVnV6Q01HcHd4UWFlV3JzNVoKaldqZU5JY0JTMHA5QXdRaC9ZbDdiUG5OVllFVm1QaW5zOW9tZ0JrRkt0K2dvV1FKSUFzRTcxUnBBb0dCQUxNSgphTW43c2RuNUo1bnA0VnhBZGFkcGFvQ2VlbURmUG9KaEd0UTNCT3RRZW5Xek9nS1p6TXJHSVpoaTNjOTNicVlzClk0Y0E4bHFzcU9IdElDVUpIdHVwNHFMdVdCZ0VjSWcvaVpzTWo4OFRTL3MvZlk5ZUJIZnFGa0N4V3RIVGhINHkKSzZucnVMZGNZV2w0RWhRcWJ2enkxUk5oQkp0Rno4Y3dMNTdRVFRDeEFvR0FjZFFkbzk3bzZmS0dQa3kvREpnNApoTGlLbHVkTjF3bmJaTHFVM0UwNzBwVDhJQkx3TFNpTUNpYXRXWWtScHFpdUQyMSsya1p4SE1NdnNoZWJXQmFmCmVqWU9jcHVvQ3VWdWc1K1dlbmc0cmUxSTl2c2czUE5WYkdrNW1xNmZDbndsbGFnMkEvSVBVRFFOaUpCVGM1WUQKc25udzhVTjNBTFBMSTlPVVd1eXJUckk9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
---
# Service (no selector) pointing to external edge1
apiVersion: v1
kind: Service
metadata:
name: voice-bridge
namespace: voice
spec:
ports:
- name: twiml
port: 8766
targetPort: 8766
protocol: TCP
- name: websocket
port: 8765
targetPort: 8765
protocol: TCP
---
# Manual Endpoints for edge1 (outside K8s)
apiVersion: v1
kind: Endpoints
metadata:
name: voice-bridge
namespace: voice
subsets:
- addresses:
- ip: 10.0.57.15
ports:
- name: twiml
port: 8766
protocol: TCP
- name: websocket
port: 8765
protocol: TCP
---
# TwiML webhook: voice.bluejay.dev -> edge1:8766
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: voice-twiml
namespace: voice
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`voice.bluejay.dev`)
services:
- name: voice-bridge
port: 8766
tls:
secretName: cf-origin-bluejay-dev
---
# WebSocket media stream: voice-ws.bluejay.dev -> edge1:8765
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: voice-ws
namespace: voice
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`voice-ws.bluejay.dev`)
services:
- name: voice-bridge
port: 8765
tls:
secretName: cf-origin-bluejay-dev
---
# NetworkPolicy: allow Traefik ingress only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: voice-netpol
namespace: voice
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
egress:
- to:
- ipBlock:
cidr: 10.0.57.15/32
ports:
- port: 8765
protocol: TCP
- port: 8766
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP

View File

@@ -0,0 +1,96 @@
zabbix_export:
version: '7.2'
template_groups:
- uuid: 30a90fb5fb3e4a7f9bb4517022c7726a
name: Templates/FlowerCore
templates:
- uuid: 89cecb27144c4b539bd8972d4d949063
template: FlowerCore Print Ollama
name: FlowerCore Print Ollama
description: FlowerCore Print.Web Ollama health probe checks. Import this template into Zabbix and link it to the Print.Web host.
groups:
- name: Templates/FlowerCore
items:
- uuid: 8fd2720255d54bc8bda0fe3ab4677c6c
name: Print.Web metrics snapshot
type: HTTP_AGENT
key: flowercore.print.ollama.snapshot
delay: 30s
history: 7d
trends: '0'
value_type: TEXT
url: http://10.0.57.16:5200/api/metrics
timeout: 5s
description: Raw JSON from Print.Web GET /api/metrics. The Ollama summary is public monitoring data; /api/ai/ollama-snapshot remains API-key protected.
- uuid: 5cb902556e9f45c2b4c29c5c4a32fd73
name: Print.Web Ollama long keep-alive runner count
type: DEPENDENT
key: flowercore.print.ollama.long_keepalive.count
delay: '0'
history: 7d
trends: 30d
value_type: UNSIGNED
description: Number of active Ollama runners whose keep-alive window remains above 10 minutes.
preprocessing:
- type: JAVASCRIPT
parameters:
- |
var payload = JSON.parse(value);
var ollama = payload.ollama || payload.Ollama || {};
var runners = ollama.runners || ollama.Runners || [];
if (!Array.isArray(runners)) {
return 0;
}
var count = 0;
for (var i = 0; i < runners.length; i += 1) {
var runner = runners[i] || {};
var markedLong = runner.longKeepAlive || runner.LongKeepAlive;
var remainingRaw = runner.keepAliveRemainingSeconds;
if (remainingRaw === undefined || remainingRaw === null) {
remainingRaw = runner.KeepAliveRemainingSeconds;
}
var remaining = Number(remainingRaw || 0);
if (markedLong === true || remaining > 600) {
count += 1;
}
}
return count;
master_item:
key: flowercore.print.ollama.snapshot
- uuid: 73680dcbbe4844f48378c9f3042641f1
name: Print.Web Ollama active runner count
type: DEPENDENT
key: flowercore.print.ollama.active_runner.count
delay: '0'
history: 7d
trends: 30d
value_type: UNSIGNED
description: Active runner count from the Print.Web Ollama snapshot.
preprocessing:
- type: JAVASCRIPT
parameters:
- |
var payload = JSON.parse(value);
var ollama = payload.ollama || payload.Ollama || {};
var activeRunnerRaw = ollama.activeRunnerCount;
if (activeRunnerRaw === undefined || activeRunnerRaw === null) {
activeRunnerRaw = ollama.ActiveRunnerCount;
}
var activeRunnerCount = Number(activeRunnerRaw);
if (!isNaN(activeRunnerCount)) {
return activeRunnerCount;
}
var runners = ollama.runners || ollama.Runners || [];
return Array.isArray(runners) ? runners.length : 0;
master_item:
key: flowercore.print.ollama.snapshot
triggers:
- uuid: 8fcd85b7e6e9423099b5e2bcbba3537e
expression: last(/FlowerCore Print Ollama/flowercore.print.ollama.long_keepalive.count)>0
name: Print.Web Ollama runner keep-alive exceeds 10 minutes
priority: WARNING
description: Print.Web reports at least one active Ollama runner with more than 10 minutes of keep-alive remaining. Check the Admin Ollama Fleet panel and stop duplicate model callers before the Pi 5 Ollama lane thrashes.
manual_close: 'YES'

View File

@@ -0,0 +1,174 @@
zabbix_export:
version: '7.2'
template_groups:
- uuid: 2ce6df1168bd4797aa5374fd19438746
name: Templates/FlowerCore
templates:
- uuid: 5b20d8f9d3c346f7b1c7fe6922e9d4d1
template: FlowerCore RemoteDesktop
name: FlowerCore RemoteDesktop
description: Optional RemoteDesktop observability import. This template reads the Prometheus exposition from FlowerCore.RemoteDesktop and extracts launch/connect/disconnect/recording counters plus warm-pool gauges. Adjust the metrics URL if the Zabbix host should scrape a different endpoint than the public desktop host.
groups:
- name: Templates/FlowerCore
items:
- uuid: 357ab8ec721a4d31a5488bdd60a6679d
name: RemoteDesktop metrics snapshot
type: HTTP_AGENT
key: flowercore.remotedesktop.metrics
delay: 30s
history: 7d
trends: '0'
value_type: TEXT
url: https://desktop.iamworkin.lan/metrics
timeout: 10s
description: Raw Prometheus exposition from FlowerCore.RemoteDesktop.
- uuid: 59af4d77fbb54dc6a733f8dc86d73c3d
name: RemoteDesktop launch events total
type: DEPENDENT
key: flowercore.remotedesktop.launch.total
delay: '0'
history: 30d
trends: 365d
value_type: FLOAT
preprocessing:
- type: JAVASCRIPT
parameters:
- |
var lines = String(value || '').split(/\r?\n/);
var sum = 0;
for (var i = 0; i < lines.length; i += 1) {
var line = lines[i];
if (line.indexOf('fc_desktop_session_events_total{') !== 0 || line.indexOf('event="launch"') === -1) {
continue;
}
var parts = line.trim().split(/\s+/);
var metricValue = Number(parts[parts.length - 1]);
if (!isNaN(metricValue)) {
sum += metricValue;
}
}
return sum;
master_item:
key: flowercore.remotedesktop.metrics
- uuid: 479e5d87f8e14e9cb4c45f1832723a34
name: RemoteDesktop connect events total (json datasource)
type: DEPENDENT
key: flowercore.remotedesktop.connect.json.total
delay: '0'
history: 30d
trends: 365d
value_type: FLOAT
preprocessing:
- type: JAVASCRIPT
parameters:
- |
var lines = String(value || '').split(/\r?\n/);
var sum = 0;
for (var i = 0; i < lines.length; i += 1) {
var line = lines[i];
if (line.indexOf('fc_desktop_session_events_total{') !== 0
|| line.indexOf('event="connect"') === -1
|| line.indexOf('browser_datasource="json"') === -1) {
continue;
}
var parts = line.trim().split(/\s+/);
var metricValue = Number(parts[parts.length - 1]);
if (!isNaN(metricValue)) {
sum += metricValue;
}
}
return sum;
master_item:
key: flowercore.remotedesktop.metrics
- uuid: 8ad073699ca74a99ab36ef1e4a4b06b8
name: RemoteDesktop disconnect events total
type: DEPENDENT
key: flowercore.remotedesktop.disconnect.total
delay: '0'
history: 30d
trends: 365d
value_type: FLOAT
preprocessing:
- type: JAVASCRIPT
parameters:
- |
var lines = String(value || '').split(/\r?\n/);
var sum = 0;
for (var i = 0; i < lines.length; i += 1) {
var line = lines[i];
if (line.indexOf('fc_desktop_session_events_total{') !== 0 || line.indexOf('event="disconnect"') === -1) {
continue;
}
var parts = line.trim().split(/\s+/);
var metricValue = Number(parts[parts.length - 1]);
if (!isNaN(metricValue)) {
sum += metricValue;
}
}
return sum;
master_item:
key: flowercore.remotedesktop.metrics
- uuid: 0a50ab8cd4ab4c97ac52f3d94b02ff8f
name: RemoteDesktop recording events total
type: DEPENDENT
key: flowercore.remotedesktop.recording.total
delay: '0'
history: 30d
trends: 365d
value_type: FLOAT
preprocessing:
- type: JAVASCRIPT
parameters:
- |
var lines = String(value || '').split(/\r?\n/);
var sum = 0;
for (var i = 0; i < lines.length; i += 1) {
var line = lines[i];
if (line.indexOf('fc_desktop_session_events_total{') !== 0 || line.indexOf('event="recording"') === -1) {
continue;
}
var parts = line.trim().split(/\s+/);
var metricValue = Number(parts[parts.length - 1]);
if (!isNaN(metricValue)) {
sum += metricValue;
}
}
return sum;
master_item:
key: flowercore.remotedesktop.metrics
- uuid: 5d4d5e7b38d14c68a72877e37d7f1bde
name: RemoteDesktop warm pools ready
type: DEPENDENT
key: flowercore.remotedesktop.pool.ready
delay: '0'
history: 30d
trends: 365d
value_type: FLOAT
preprocessing:
- type: JAVASCRIPT
parameters:
- |
var lines = String(value || '').split(/\r?\n/);
var sum = 0;
for (var i = 0; i < lines.length; i += 1) {
var line = lines[i];
if (line.indexOf('fc_desktop_pool_ready{') !== 0) {
continue;
}
var parts = line.trim().split(/\s+/);
var metricValue = Number(parts[parts.length - 1]);
if (!isNaN(metricValue)) {
sum += metricValue;
}
}
return sum;
master_item:
key: flowercore.remotedesktop.metrics
valuemaps: []
triggers:
- uuid: 5ef71c752fa94d2e8ce3ced79fcfe0f4
expression: nodata(/FlowerCore RemoteDesktop/flowercore.remotedesktop.metrics,10m)=1
name: FlowerCore RemoteDesktop metrics unavailable
priority: WARNING
description: FlowerCore.RemoteDesktop /metrics has not returned data for 10 minutes. Check the web deployment, ingress, or the scrape URL configured in this template.
manual_close: 'YES'

View File

@@ -1,360 +1,371 @@
# Zabbix 7.2 Monitoring Stack
# PostgreSQL 16 + Zabbix Server + Zabbix Web (nginx)
# ArgoCD managed - BlueJay Lab
# Credentials sourced from 1Password via OnePasswordItem CRD (zabbix-credentials)
---
apiVersion: v1
kind: Namespace
metadata:
name: zabbix
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# PostgreSQL 16 StatefulSet
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: zabbix-postgres
# Zabbix 7.2 Monitoring Stack
# PostgreSQL 16 + Zabbix Server + Zabbix Web (nginx)
# ArgoCD managed - BlueJay Lab
# Credentials sourced from 1Password via OnePasswordItem CRD (zabbix-credentials)
---
apiVersion: v1
kind: Namespace
metadata:
name: zabbix
labels:
app.kubernetes.io/part-of: bluejay-infra
---
# PostgreSQL 16 StatefulSet
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: zabbix-postgres
namespace: zabbix
labels:
app: zabbix-postgres
argocd.argoproj.io/instance: infra-zabbix
spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
podManagementPolicy: OrderedReady
serviceName: zabbix-postgres
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app: zabbix-postgres
template:
metadata:
labels:
app: zabbix-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
args:
- "-c"
- "shared_buffers=256MB"
- "-c"
- "effective_cache_size=512MB"
- "-c"
- "work_mem=16MB"
- "-c"
- "maintenance_work_mem=128MB"
- "-c"
- "random_page_cost=1.1"
- "-c"
- "effective_io_concurrency=200"
- "-c"
- "max_connections=50"
- "-c"
- "checkpoint_completion_target=0.9"
- "-c"
- "wal_buffers=8MB"
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-Password
- name: POSTGRES_DB
value: zabbix
volumeMounts:
- name: zabbix-postgres-data
mountPath: /var/lib/postgresql/data
subPath: pgdata
resources:
requests:
memory: 512Mi
cpu: 200m
limits:
memory: 1Gi
cpu: "1"
livenessProbe:
exec:
command:
- pg_isready
- -U
- zabbix
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- zabbix
initialDelaySeconds: 5
periodSeconds: 5
volumeClaimTemplates:
- metadata:
name: zabbix-postgres-data
spec:
accessModes: [ReadWriteOnce]
template:
metadata:
labels:
app: zabbix-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
args:
- "-c"
- "shared_buffers=256MB"
- "-c"
- "effective_cache_size=512MB"
- "-c"
- "work_mem=16MB"
- "-c"
- "maintenance_work_mem=128MB"
- "-c"
- "random_page_cost=1.1"
- "-c"
- "effective_io_concurrency=200"
- "-c"
- "max_connections=50"
- "-c"
- "checkpoint_completion_target=0.9"
- "-c"
- "wal_buffers=8MB"
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-Password
- name: POSTGRES_DB
value: zabbix
volumeMounts:
- name: zabbix-postgres-data
mountPath: /var/lib/postgresql/data
subPath: pgdata
resources:
requests:
memory: 512Mi
cpu: 200m
limits:
memory: 1Gi
cpu: "1"
livenessProbe:
exec:
command:
- pg_isready
- -U
- zabbix
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- zabbix
initialDelaySeconds: 5
periodSeconds: 5
volumeClaimTemplates:
- metadata:
name: zabbix-postgres-data
spec:
accessModes: [ReadWriteOnce]
volumeMode: Filesystem
resources:
requests:
storage: 10Gi
updateStrategy:
rollingUpdate:
partition: 0
type: RollingUpdate
---
apiVersion: v1
kind: Service
metadata:
name: zabbix-postgres
namespace: zabbix
spec:
selector:
app: zabbix-postgres
ports:
- port: 5432
targetPort: 5432
name: postgres
clusterIP: None
---
# Zabbix Server
apiVersion: apps/v1
kind: Deployment
metadata:
name: zabbix-server
namespace: zabbix
labels:
app: zabbix-server
spec:
replicas: 1
selector:
matchLabels:
app: zabbix-server
template:
metadata:
labels:
app: zabbix-server
spec:
containers:
- name: zabbix-server
image: zabbix/zabbix-server-pgsql:7.2-alpine-latest
ports:
- containerPort: 10051
name: trapper
env:
- name: DB_SERVER_HOST
value: zabbix-postgres
- name: DB_SERVER_PORT
value: "5432"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-Password
- name: POSTGRES_DB
value: zabbix
- name: ZBX_CACHESIZE
value: "64M"
- name: ZBX_VALUECACHESIZE
value: "64M"
- name: ZBX_HISTORYCACHESIZE
value: "32M"
- name: ZBX_TRENDCACHESIZE
value: "8M"
- name: ZBX_STARTPOLLERS
value: "10"
- name: ZBX_STARTPOLLERSUNREACHABLE
value: "3"
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: "1"
livenessProbe:
tcpSocket:
port: 10051
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 10051
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: zabbix-server
namespace: zabbix
spec:
selector:
app: zabbix-server
ports:
- port: 10051
targetPort: 10051
name: trapper
---
apiVersion: v1
kind: Service
metadata:
name: zabbix-trapper
namespace: zabbix
annotations:
metallb.universe.tf/loadBalancerIPs: 10.0.56.203
spec:
type: LoadBalancer
selector:
app: zabbix-server
ports:
- port: 10051
targetPort: 10051
name: trapper
protocol: TCP
---
# Zabbix Web (nginx + PostgreSQL)
apiVersion: apps/v1
kind: Deployment
metadata:
name: zabbix-web
namespace: zabbix
labels:
app: zabbix-web
spec:
replicas: 1
selector:
matchLabels:
app: zabbix-web
template:
metadata:
labels:
app: zabbix-web
spec:
containers:
- name: zabbix-web
image: zabbix/zabbix-web-nginx-pgsql:7.2-alpine-latest
ports:
- containerPort: 8080
name: http
env:
- name: ZBX_SERVER_HOST
value: zabbix-server
- name: ZBX_SERVER_NAME
value: "BlueJay NOC"
- name: PHP_TZ
value: America/Chicago
- name: DB_SERVER_HOST
value: zabbix-postgres
- name: DB_SERVER_PORT
value: "5432"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-Password
- name: POSTGRES_DB
value: zabbix
- name: ZBX_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: password
- name: ZBX_MEMORYLIMIT
value: "256M"
- name: PHP_FPM_PM_MAX_CHILDREN
value: "10"
- name: PHP_FPM_PM_START_SERVERS
value: "3"
- name: PHP_FPM_PM_MIN_SPARE_SERVERS
value: "2"
- name: PHP_FPM_PM_MAX_SPARE_SERVERS
value: "5"
- name: PHP_FPM_PM_MAX_REQUESTS
value: "500"
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 768Mi
cpu: 500m
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 60
timeoutSeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: zabbix-web
namespace: zabbix
spec:
selector:
app: zabbix-web
ports:
- port: 8080
targetPort: 8080
name: http
---
# TLS Certificate via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: zabbix-tls
namespace: zabbix
spec:
secretName: zabbix-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- zabbix.iamworkin.lan
---
# Traefik IngressRoute
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: zabbix-web
namespace: zabbix
spec:
entryPoints:
- websecure
routes:
- match: Host(`zabbix.iamworkin.lan`)
kind: Rule
services:
- name: zabbix-web
port: 8080
tls:
secretName: zabbix-tls
---
# 1Password secret sync — creates zabbix-credentials K8s Secret
# Fields: DB-User, DB-Password, username, password, URL
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: zabbix-credentials
namespace: zabbix
spec:
itemPath: vaults/IAmWorkin/items/Zabbix Admin
metadata:
name: zabbix-postgres
namespace: zabbix
spec:
selector:
app: zabbix-postgres
ports:
- port: 5432
targetPort: 5432
name: postgres
clusterIP: None
---
# Zabbix Server
apiVersion: apps/v1
kind: Deployment
metadata:
name: zabbix-server
namespace: zabbix
labels:
app: zabbix-server
spec:
replicas: 1
selector:
matchLabels:
app: zabbix-server
template:
metadata:
labels:
app: zabbix-server
spec:
containers:
- name: zabbix-server
image: zabbix/zabbix-server-pgsql:7.2-alpine-latest
ports:
- containerPort: 10051
name: trapper
env:
- name: DB_SERVER_HOST
value: zabbix-postgres
- name: DB_SERVER_PORT
value: "5432"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-Password
- name: POSTGRES_DB
value: zabbix
- name: ZBX_CACHESIZE
value: "64M"
- name: ZBX_VALUECACHESIZE
value: "64M"
- name: ZBX_HISTORYCACHESIZE
value: "32M"
- name: ZBX_TRENDCACHESIZE
value: "8M"
- name: ZBX_STARTPOLLERS
value: "10"
- name: ZBX_STARTPOLLERSUNREACHABLE
value: "3"
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: "1"
livenessProbe:
tcpSocket:
port: 10051
initialDelaySeconds: 60
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 10051
initialDelaySeconds: 30
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: zabbix-server
namespace: zabbix
spec:
selector:
app: zabbix-server
ports:
- port: 10051
targetPort: 10051
name: trapper
---
apiVersion: v1
kind: Service
metadata:
name: zabbix-trapper
namespace: zabbix
annotations:
metallb.universe.tf/loadBalancerIPs: 10.0.56.203
spec:
type: LoadBalancer
selector:
app: zabbix-server
ports:
- port: 10051
targetPort: 10051
name: trapper
protocol: TCP
---
# Zabbix Web (nginx + PostgreSQL)
apiVersion: apps/v1
kind: Deployment
metadata:
name: zabbix-web
namespace: zabbix
labels:
app: zabbix-web
spec:
replicas: 1
selector:
matchLabels:
app: zabbix-web
template:
metadata:
labels:
app: zabbix-web
spec:
containers:
- name: zabbix-web
image: zabbix/zabbix-web-nginx-pgsql:7.2-alpine-latest
ports:
- containerPort: 8080
name: http
env:
- name: ZBX_SERVER_HOST
value: zabbix-server
- name: ZBX_SERVER_NAME
value: "BlueJay NOC"
- name: PHP_TZ
value: America/Chicago
- name: DB_SERVER_HOST
value: zabbix-postgres
- name: DB_SERVER_PORT
value: "5432"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-User
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: DB-Password
- name: POSTGRES_DB
value: zabbix
- name: ZBX_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: zabbix-credentials
key: password
- name: ZBX_MEMORYLIMIT
value: "256M"
- name: PHP_FPM_PM_MAX_CHILDREN
value: "10"
- name: PHP_FPM_PM_START_SERVERS
value: "3"
- name: PHP_FPM_PM_MIN_SPARE_SERVERS
value: "2"
- name: PHP_FPM_PM_MAX_SPARE_SERVERS
value: "5"
- name: PHP_FPM_PM_MAX_REQUESTS
value: "500"
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 768Mi
cpu: 500m
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 60
timeoutSeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: zabbix-web
namespace: zabbix
spec:
selector:
app: zabbix-web
ports:
- port: 8080
targetPort: 8080
name: http
---
# TLS Certificate via cert-manager
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: zabbix-tls
namespace: zabbix
spec:
secretName: zabbix-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- zabbix.iamworkin.lan
---
# Traefik IngressRoute
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: zabbix-web
namespace: zabbix
spec:
entryPoints:
- websecure
routes:
- match: Host(`zabbix.iamworkin.lan`)
kind: Rule
services:
- name: zabbix-web
port: 8080
tls:
secretName: zabbix-tls
---
# 1Password secret sync — creates zabbix-credentials K8s Secret
# Fields: DB-User, DB-Password, username, password, URL
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: zabbix-credentials
namespace: zabbix
spec:
itemPath: vaults/IAmWorkin/items/Zabbix Admin

View File

@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""
check-pfsense-dns.py
Historical name retained for continuity, but the check now runs through the
public FlowerCore.DNS preflight API instead of a raw local resolver lookup.
Fails if any *.iamworkin.lan hostname referenced by a cert-manager Certificate
`spec.dnsNames` or a Traefik IngressRoute `Host(...)` match rule is NOT
resolvable via FlowerCore.DNS:
GET /api/v1/zones/{zone}/resolve-preflight?hostname=<host>
Two sources are scanned:
1. apps/*/*.yaml in this bluejay-infra checkout — the pre-merge gate.
2. Live-cluster Certificates + IngressRoutes (opt-in with --live, or auto when
kubectl is on PATH AND kubeconfig is usable). This catches hostnames that
exist in the running cluster but aren't (yet) tracked in bluejay-infra —
e.g. services deployed via their own repo's deploy script. Retail.Web on
2026-04-23 was stuck Issuing for 15h because of exactly this gap.
Run from anywhere that can reach the FlowerCore.DNS host:
python scripts/check-pfsense-dns.py # auto live scan if kubectl works
python scripts/check-pfsense-dns.py --live # require live scan
python scripts/check-pfsense-dns.py --no-live # manifests only (CI default)
Exit code 0: all referenced hosts pass FlowerCore.DNS preflight.
Exit code 1: at least one host fails preflight.
Exit code 2: --live requested but kubectl was unusable.
This is intentionally narrow: it only flags hostnames that cert-manager will
actually try to validate or that Traefik will route. IRC server-link names,
Docker image tags, comments, etc. are ignored.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import ssl
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
try:
import yaml # PyYAML
except ImportError:
sys.exit("PyYAML required: pip install pyyaml")
REPO_ROOT = Path(__file__).resolve().parent.parent
APPS_DIR = REPO_ROOT / "apps"
HOST_RE = re.compile(r"Host\(`([^`]+)`\)")
LIVE_SOURCE = "live-cluster"
DEFAULT_BASE_URL = os.environ.get("FLOWERCORE_DNS_BASE_URL", "https://dns.iamworkin.lan")
DEFAULT_ZONE = os.environ.get("FLOWERCORE_DNS_ZONE", "iamworkin.lan")
DEFAULT_TIMEOUT_SECONDS = float(os.environ.get("FLOWERCORE_DNS_TIMEOUT_SECONDS", "20"))
DEFAULT_WORKERS = max(1, int(os.environ.get("FLOWERCORE_DNS_WORKERS", "8")))
@dataclass(frozen=True)
class PreflightResult:
host: str
ok: bool
resolved_zone: str | None
server_name: str | None
provider: str | None
addresses: list[str]
challenge_fqdn: str
message: str
def extract_hosts_from_doc(doc: dict) -> set[str]:
"""Pull iamworkin.lan hostnames from a single K8s manifest doc."""
out: set[str] = set()
if not isinstance(doc, dict):
return out
kind = doc.get("kind", "")
spec = doc.get("spec") or {}
if kind == "Certificate":
for name in spec.get("dnsNames", []) or []:
if isinstance(name, str) and name.endswith(".iamworkin.lan"):
out.add(name)
elif kind == "IngressRoute":
for route in spec.get("routes", []) or []:
match = route.get("match", "") if isinstance(route, dict) else ""
for h in HOST_RE.findall(match):
if h.endswith(".iamworkin.lan"):
out.add(h)
return out
def collect_hosts_from_manifests() -> dict[str, list[str]]:
"""hostname -> [list of manifest files that referenced it]."""
index: dict[str, list[str]] = {}
for path in sorted(APPS_DIR.rglob("*.yaml")):
try:
with path.open("r", encoding="utf-8") as f:
for doc in yaml.safe_load_all(f):
for host in extract_hosts_from_doc(doc):
index.setdefault(host, []).append(str(path.relative_to(REPO_ROOT)))
except yaml.YAMLError as e:
print(f"warn: could not parse {path}: {e}", file=sys.stderr)
return index
def _kubectl_json(args: list[str]) -> dict | None:
"""Run `kubectl ... -o json` and return the parsed result, or None on failure."""
try:
r = subprocess.run(
["kubectl", *args, "-o", "json"],
capture_output=True,
text=True,
timeout=20,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if r.returncode != 0:
return None
try:
return json.loads(r.stdout)
except json.JSONDecodeError:
return None
def collect_hosts_from_cluster() -> tuple[dict[str, list[str]], bool]:
"""
Scan live cluster. Returns (host_index, ok).
ok=False means kubectl wasn't usable; the caller decides whether that's
fatal (--live) or just a warning (auto mode).
"""
if not shutil.which("kubectl"):
return {}, False
index: dict[str, list[str]] = {}
certs = _kubectl_json(["get", "certificate", "-A"])
if certs is None:
return {}, False
for item in certs.get("items", []):
meta = item.get("metadata", {})
ns = meta.get("namespace", "?")
name = meta.get("name", "?")
ref = f"{LIVE_SOURCE} Certificate {ns}/{name}"
for dn in (item.get("spec", {}) or {}).get("dnsNames", []) or []:
if isinstance(dn, str) and dn.endswith(".iamworkin.lan"):
index.setdefault(dn, []).append(ref)
irs = _kubectl_json(["get", "ingressroute", "-A"])
if irs is not None:
for item in irs.get("items", []):
meta = item.get("metadata", {})
ns = meta.get("namespace", "?")
name = meta.get("name", "?")
ref = f"{LIVE_SOURCE} IngressRoute {ns}/{name}"
for route in (item.get("spec", {}) or {}).get("routes", []) or []:
match = route.get("match", "") if isinstance(route, dict) else ""
for h in HOST_RE.findall(match):
if h.endswith(".iamworkin.lan"):
index.setdefault(h, []).append(ref)
return index, True
def _ssl_context(insecure: bool) -> ssl.SSLContext:
return ssl._create_unverified_context() if insecure else ssl.create_default_context()
def preflight_host(
base_url: str,
zone: str,
host: str,
timeout_seconds: float,
insecure: bool,
) -> PreflightResult:
path = (
f"/api/v1/zones/{urllib.parse.quote(zone, safe='')}/resolve-preflight"
f"?hostname={urllib.parse.quote(host, safe='')}"
)
url = urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
request = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(
request,
timeout=timeout_seconds,
context=_ssl_context(insecure),
) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace").strip()
detail = body[:200] if body else exc.reason
return PreflightResult(
host=host,
ok=False,
resolved_zone=None,
server_name=None,
provider=None,
addresses=[],
challenge_fqdn=f"_acme-challenge.{host.rstrip('.')}.",
message=f"HTTP {exc.code}: {detail}",
)
except Exception as exc: # noqa: BLE001 - surfaced as preflight failure detail
return PreflightResult(
host=host,
ok=False,
resolved_zone=None,
server_name=None,
provider=None,
addresses=[],
challenge_fqdn=f"_acme-challenge.{host.rstrip('.')}.",
message=f"{type(exc).__name__}: {exc}",
)
resolved_zone = payload.get("resolvedZone")
server_name = payload.get("serverName")
provider = payload.get("provider")
addresses = [value for value in payload.get("addresses", []) if isinstance(value, str)]
supports_acme = bool(payload.get("supportsAcmeDns01"))
resolvable = bool(payload.get("resolvable"))
challenge_fqdn = str(payload.get("challengeFqdn", f"_acme-challenge.{host.rstrip('.')}."))
message = str(payload.get("message", "")).strip()
if not supports_acme and not message:
message = "Matched DNS server does not advertise ACME DNS-01 support."
ok = supports_acme and resolvable and bool(resolved_zone)
return PreflightResult(
host=host,
ok=ok,
resolved_zone=resolved_zone,
server_name=server_name,
provider=provider,
addresses=addresses,
challenge_fqdn=challenge_fqdn,
message=message,
)
def run_preflight(
hosts: list[str],
base_url: str,
zone: str,
timeout_seconds: float,
insecure: bool,
workers: int,
) -> dict[str, PreflightResult]:
if not hosts:
return {}
max_workers = max(1, min(workers, len(hosts)))
results: dict[str, PreflightResult] = {}
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_map = {
pool.submit(preflight_host, base_url, zone, host, timeout_seconds, insecure): host
for host in hosts
}
for future in as_completed(future_map):
host = future_map[future]
results[host] = future.result()
return results
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[1] if __doc__ else None)
parser.add_argument(
"--live",
dest="live",
action="store_true",
default=None,
help="Require a live-cluster scan (fail if kubectl unreachable).",
)
parser.add_argument(
"--no-live",
dest="live",
action="store_false",
help="Skip live-cluster scan (manifests only).",
)
parser.add_argument(
"--base-url",
default=DEFAULT_BASE_URL,
help=f"FlowerCore.DNS base URL (default: {DEFAULT_BASE_URL}).",
)
parser.add_argument(
"--zone",
default=DEFAULT_ZONE,
help=f"Zone passed to resolve-preflight (default: {DEFAULT_ZONE}).",
)
parser.add_argument(
"--timeout-seconds",
type=float,
default=DEFAULT_TIMEOUT_SECONDS,
help=f"Per-host resolve-preflight timeout (default: {DEFAULT_TIMEOUT_SECONDS}).",
)
parser.add_argument(
"--workers",
type=int,
default=DEFAULT_WORKERS,
help=f"Parallel preflight workers (default: {DEFAULT_WORKERS}).",
)
parser.add_argument(
"--insecure",
action="store_true",
help="Skip TLS verification when calling FlowerCore.DNS.",
)
args = parser.parse_args()
hosts = collect_hosts_from_manifests()
live_requested = args.live is True
live_auto = args.live is None
if live_requested or live_auto:
live_hosts, live_ok = collect_hosts_from_cluster()
if live_requested and not live_ok:
print("ERROR: --live requested but kubectl is not available or auth failed.", file=sys.stderr)
return 2
if live_ok:
before = len(hosts)
for host, refs in live_hosts.items():
hosts.setdefault(host, []).extend(refs)
new_hosts = len(hosts) - before
print(f"(live scan: {len(live_hosts)} cluster host(s); {new_hosts} not covered by manifests)")
elif live_auto:
print("(kubectl not reachable — skipping live scan; run from a workstation with cluster access to catch retail-style drift)")
if not hosts:
print("No iamworkin.lan hostnames found in manifests or cluster — nothing to check.")
return 0
print(
f"(preflight: {len(hosts)} host(s) via {args.base_url.rstrip('/')}"
f"/api/v1/zones/{args.zone}/resolve-preflight)"
)
results = run_preflight(
sorted(hosts),
base_url=args.base_url,
zone=args.zone,
timeout_seconds=args.timeout_seconds,
insecure=args.insecure,
workers=args.workers,
)
failed: list[tuple[str, list[str], PreflightResult]] = []
for host in sorted(hosts):
result = results[host]
if result.ok:
addresses = ", ".join(result.addresses) if result.addresses else "(no A/AAAA answers)"
zone_label = result.resolved_zone or args.zone
server_label = result.server_name or "unknown-server"
print(f"OK {host:<45} -> {addresses} via {server_label} [{zone_label}]")
else:
print(f"FAIL {host:<45} ({result.message})")
failed.append((host, hosts[host], result))
if failed:
print()
print(f"ERROR: {len(failed)} host(s) failed FlowerCore.DNS preflight.")
for host, refs, result in failed:
print(f" {host}")
print(f" preflight: {result.message}")
print(f" challenge: {result.challenge_fqdn}")
for ref in sorted(set(refs)):
print(f" via: {ref}")
print()
print("Fix the DNS record in FlowerCore.DNS before merging, then rerun this gate.")
print()
print("Example:")
print(f" curl -sk {args.base_url.rstrip('/')}/api/v1/servers")
print(
" curl -sk -X POST "
f"{args.base_url.rstrip('/')}/api/v1/servers/<serverId>/zones/{args.zone}/records "
"-H 'Content-Type: application/json' "
"-d '{\"name\":\"<host>\",\"type\":\"A\",\"data\":\"10.0.56.200\",\"ttl\":300}'"
)
return 1
print()
print(f"All {len(hosts)} iamworkin.lan host(s) passed FlowerCore.DNS preflight. Safe to deploy.")
return 0
if __name__ == "__main__":
sys.exit(main())