Compare commits

..

6 Commits

Author SHA1 Message Date
Codex
a0f8fd1790 chore(github-runner): gate token provisioning 2026-05-15 17:32:14 -05:00
Andrew Stoltz
7d2daaa4f8 chore(github-runner): replicas 1 → 0 until 1Password token provisioned
github-runner-token OnePasswordItem exists but the underlying 1Password
vault item hasn't been created yet, so the operator can't mint the K8s
Secret. Pod stuck in CreateContainerConfigError → DeploymentReplicasMismatch
alert fires.

Scaling to 0 keeps the manifest infrastructure intact but stops trying
to schedule until operator:
1. Creates "GitHub Runner Registration Token" item in IAmWorkin vault
2. Generates a token at github.com/astoltz/<repo>/settings/actions/runners/new
3. Updates the OnePasswordItem itemPath to point at it
4. Bumps replicas back to 1 via PR
2026-05-15 16:18:19 -05:00
Andrew Stoltz
e50e103ba0 fix(zabbix): bump web probe timeouts 5s→15s + add failureThreshold
zabbix-web nginx+PHP-FPM container serves / at ~3-5s baseline with
occasional 6-7s spikes (probe path renders full dashboard via PHP).
kube-probe was killing the container after 3 consecutive 5s-timeout
499s, producing CrashLoopBackOff alert noise even though the app
was serving real traffic fine.

15s timeout absorbs the natural variance; explicit failureThreshold=3
documents the policy (was implicit default).

Closes the firing PodCrashLoopBackOff (zabbix-web) + pending
HTTPServiceSlow/HTTPServiceDegraded alerts. zabbix.iamworkin.lan
remains slow at the application layer (separate work — PHP-FPM
warm-up + Zabbix server "host not found" agent lookup spam need
their own fixes) but the pod restart loop stops.
2026-05-15 15:59:04 -05:00
Codex
e8094eb0bd ci(github-runner): add Phase 2 ephemeral Linux runner K8s manifest
Namespace github-runner with myoung34/github-runner:latest Deployment,
5Gi Longhorn RWO NuGet cache PVC, zero-privilege ServiceAccount, and
OnePasswordItem CRD for the registration token. EPHEMERAL=true mode
re-registers after each job; Recreate strategy avoids RWO multi-attach.
Targets fc-build-linux label; single replica pinned to rke2-server node.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:46:25 -05:00
8d87d9172c Add Pi signage Phase 1 player artifacts
Squash merge Sprint 14 Pi signage player artifacts.
2026-05-14 01:46:09 +00:00
Codex
cfd9743afa Add Apple TV signage docs manifest 2026-05-13 20:32:48 -05:00
59 changed files with 1038 additions and 1128 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
# fc-signage-appletv
Apple TV signage is a sealed appliance running the `FlowerCore.Signage.Agent.AppleTv` tvOS app per ADR-134.
This ApplicationSet entry is documentation and inventory metadata only. It intentionally creates no `Deployment`, `Service`, or `Pod`.
The Apple TV app connects outbound to existing FC.Signage.Web surfaces:
- `https://signage.iamworkin.lan/hub/signage` for SignalR live status.
- `GET /api/v1/nodes/{nodeId}/state` for the 30 second polling fallback.
- `POST /api/v1/nodes/register` and `POST /api/v1/nodes/{nodeId}/enroll` for pairing and mTLS enrollment.
- `POST /api/v1/nodes/{nodeId}/heartbeat` for metrics, current content identity, and local audit excerpts.
Distribution is via Apple Developer Enterprise Program or TestFlight plus FC.Distribution / UpdateCenter publishing once Apple credentials are available.

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- manifest.yaml

View File

@@ -0,0 +1,26 @@
# Apple TV signage is a sealed tvOS appliance. This ArgoCD app intentionally
# carries documentation metadata only; no Deployment, Service, or Pod resources
# are created for the player.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-signage-appletv-docs
namespace: fc-signage
labels:
app.kubernetes.io/name: fc-signage-appletv
app.kubernetes.io/part-of: flowercore-signage
flowercore.io/manifest-kind: docs-only
data:
README: |
FlowerCore.Signage.Agent.AppleTv is distributed through Apple Developer
Enterprise Program or TestFlight, not Kubernetes.
The app connects outbound to FC.Signage.Web:
- SignalR: https://signage.iamworkin.lan/hub/signage
- Polling fallback: GET /api/v1/nodes/{nodeId}/state
- Enrollment: POST /api/v1/nodes/{nodeId}/enroll
- Heartbeat: POST /api/v1/nodes/{nodeId}/heartbeat
This placeholder gives ArgoCD and inventory dashboards a first-class
Apple TV signage app entry without creating runtime pods.

View File

@@ -0,0 +1,17 @@
# FlowerCore Signage Pi Player
Phase 1 Raspberry Pi signage player packaging for Chromium kiosk deployments.
This bundle is intentionally air-gap friendly: systemd units, shell scripts,
udev rules, and Chromium managed policy are all checked into the repo and are
installed by `FlowerCore.Puppet`.
## Scope
- Bootstrap a stable node identity and mTLS client certificate.
- Launch Chromium in kiosk mode against `FC.Signage.Web` player routes.
- Restart the kiosk on HDMI hotplug.
- Renew mTLS certificates daily when fewer than 30 days remain.
- Detect display capabilities at boot, daily, and on HDMI hotplug.
Phase 2 native Avalonia rendering is documented separately in Notes and remains
deferred.

View File

@@ -0,0 +1,15 @@
{
"AutofillAddressEnabled": false,
"AutofillCreditCardEnabled": false,
"PasswordManagerEnabled": false,
"BrowserSignin": 0,
"MetricsReportingEnabled": false,
"SafeBrowsingProtectionLevel": 0,
"DefaultNotificationsSetting": 2,
"DefaultPopupsSetting": 2,
"BackgroundModeEnabled": false,
"DefaultBrowserSettingEnabled": false,
"PromotionalTabsEnabled": false,
"CommandLineFlagSecurityWarningsEnabled": false,
"ExtensionInstallBlocklist": ["*"]
}

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
NODE_JSON="/etc/flowercore/signage-node.json"
CERT_DIR="/etc/fc-signage-player"
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
NODE_ID=$(jq -r '.nodeId' "$NODE_JSON")
CONNECTORS=()
for dir in /sys/class/drm/card*-HDMI-A-*; do
[[ -e "$dir/status" ]] || continue
if [[ "$(cat "$dir/status")" == "connected" ]]; then
CONNECTORS+=("$(basename "$dir")")
fi
done
if [[ ${#CONNECTORS[@]} -eq 0 ]]; then
CAPABILITIES_JSON=$(jq -n --arg id "$NODE_ID" '{
nodeId: $id,
platform: "linux-arm64-pi",
displayConnected: false,
detectedAt: (now | todate),
note: "No HDMI display detected"
}')
else
PRIMARY="${CONNECTORS[0]}"
EDID_PATH="/sys/class/drm/${PRIMARY}/edid"
WIDTH=0
HEIGHT=0
REFRESH=60
HDR=false
AUDIO_HDMI=false
MFG=""
MODEL=""
PHYSICAL_SIZE=null
if [[ -s "$EDID_PATH" ]] && command -v edid-decode >/dev/null 2>&1; then
EDID_INFO=$(edid-decode < "$EDID_PATH" 2>/dev/null || true)
MFG=$(echo "$EDID_INFO" | grep -m1 -oP 'Manufacturer:\s*\K\S+' || true)
MODEL=$(echo "$EDID_INFO" | grep -m1 -oP 'Model:\s*\K\S+' || true)
PREF=$(echo "$EDID_INFO" | grep -m1 -oP '\d+x\d+\s*@\s*\d+(?:\.\d+)?\s*Hz' || true)
if [[ -n "$PREF" ]]; then
WIDTH=$(echo "$PREF" | grep -oP '^\d+')
HEIGHT=$(echo "$PREF" | grep -oP 'x\K\d+')
REFRESH=$(echo "$PREF" | grep -oP '@\s*\K[\d.]+' | cut -d. -f1)
fi
if echo "$EDID_INFO" | grep -qiE 'HDR (Static|Dynamic) Metadata Block'; then HDR=true; fi
if echo "$EDID_INFO" | grep -qiE 'CEA Audio Block|Audio Format Descriptor'; then AUDIO_HDMI=true; fi
PH_W=$(echo "$EDID_INFO" | grep -m1 -oP 'Maximum image size:\s*\K\d+\s*cm\s*x\s*\d+' || true)
if [[ -n "$PH_W" ]]; then
PH_CM_W=$(echo "$PH_W" | grep -oP '^\d+')
PH_CM_H=$(echo "$PH_W" | grep -oP 'x\s*\K\d+')
if (( PH_CM_W > 0 && PH_CM_H > 0 )); then
PHYSICAL_SIZE=$(awk -v w="$PH_CM_W" -v h="$PH_CM_H" 'BEGIN { printf "%.1f", sqrt(w*w + h*h)/2.54 }')
fi
fi
fi
if [[ "$WIDTH" == "0" ]] && command -v kmsprint >/dev/null 2>&1; then
KMS=$(kmsprint 2>/dev/null | grep -A2 "$PRIMARY" | grep -oP '\d+x\d+' | head -1 || true)
if [[ -n "$KMS" ]]; then
WIDTH=$(echo "$KMS" | grep -oP '^\d+')
HEIGHT=$(echo "$KMS" | grep -oP 'x\K\d+')
fi
fi
AUDIO_ALSA=false
if aplay -l 2>/dev/null | grep -qi 'card.*HDMI'; then AUDIO_ALSA=true; fi
HAS_AUDIO=false
if [[ "$AUDIO_HDMI" == "true" && "$AUDIO_ALSA" == "true" ]]; then HAS_AUDIO=true; fi
CAPABILITIES_JSON=$(jq -n \
--arg id "$NODE_ID" \
--argjson w "$WIDTH" \
--argjson h "$HEIGHT" \
--argjson r "$REFRESH" \
--argjson hdr "$HDR" \
--argjson audio "$HAS_AUDIO" \
--arg connector "$PRIMARY" \
--arg mfg "$MFG" \
--arg model "$MODEL" \
--argjson size "$PHYSICAL_SIZE" \
'{
nodeId: $id,
platform: "linux-arm64-pi",
displayConnected: true,
detectedAt: (now | todate),
hardware: {
maxResolution: { width: $w, height: $h },
nativeResolution: { width: $w, height: $h },
refreshRateHz: $r,
colorDepth: ($hdr | if . then "Color30Hdr" else "Color24" end),
hasAudioOutput: $audio,
audioChannelCount: ($audio | if . then 2 else 0 end),
physicalSizeInches: $size,
connector: $connector,
manufacturer: $mfg,
modelName: $model
},
render: { codecs: ["h264", "vp9", "mp4"] }
}')
fi
ENDPOINT_CANDIDATES=(
"${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/capabilities"
"${SIGNAGE_URL}/api/v1/displays/${NODE_ID}/capability-profile"
)
SUCCESS=false
for url in "${ENDPOINT_CANDIDATES[@]}"; do
HTTP_STATUS=$(curl -sk -o /tmp/cap-response.json -w "%{http_code}" \
--max-time 10 \
--cert "$CERT_DIR/client.crt" --key "$CERT_DIR/client.key" \
-X POST "$url" \
-H "Content-Type: application/json" \
-d "$CAPABILITIES_JSON" || echo "000")
if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" || "$HTTP_STATUS" == "204" ]]; then
SUCCESS=true
break
fi
done
mkdir -p /var/log/fc-signage-player
if [[ "$SUCCESS" != "true" ]]; then
echo "[$(date -Is)] capability declare: no endpoint accepted the profile; logging locally" \
| tee -a /var/log/fc-signage-player/capabilities.log
echo "$CAPABILITIES_JSON" | tee -a /var/log/fc-signage-player/capabilities.log
else
echo "[$(date -Is)] capability declare: ok ($url)" | tee -a /var/log/fc-signage-player/capabilities.log
fi
echo "$CAPABILITIES_JSON"

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
NODE_JSON="/etc/flowercore/signage-node.json"
CERT_DIR="/etc/fc-signage-player"
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
SETUP_CODE_FILE="/etc/flowercore/signage-setup-code"
mkdir -p /etc/flowercore "$CERT_DIR" /var/log/fc-signage-player
chown fc-signage:fc-signage /etc/flowercore "$CERT_DIR" /var/log/fc-signage-player
chmod 0750 "$CERT_DIR"
if [[ -s "$NODE_JSON" && -s "$CERT_DIR/client.p12" ]]; then
ENROLLED=$(jq -r '.enrolledAt // empty' "$NODE_JSON")
if [[ -n "$ENROLLED" ]]; then
echo "[$(date -Is)] bootstrap: already enrolled at $ENROLLED; skipping"
exit 0
fi
fi
if [[ -s "$NODE_JSON" ]]; then
NODE_UUID=$(jq -r '.nodeUuid // empty' "$NODE_JSON")
MACHINE_ID=$(jq -r '.machineId // empty' "$NODE_JSON")
else
NODE_UUID=$(uuidgen)
MACHINE_ID=$(echo "$NODE_UUID" | tr -d '-' | cut -c1-16)
jq -n --arg uuid "$NODE_UUID" --arg machine "$MACHINE_ID" --arg host "$(hostname -f)" --arg ts "$(date -Is)" \
'{nodeUuid: $uuid, machineId: $machine, hostname: $host, platform: "linux-arm64-pi", createdAt: $ts}' \
> "$NODE_JSON"
chmod 0640 "$NODE_JSON"
chown fc-signage:fc-signage "$NODE_JSON"
fi
SETUP_CODE=""
if [[ -s "$SETUP_CODE_FILE" ]]; then
SETUP_CODE=$(tr -d '\r\n\t ' < "$SETUP_CODE_FILE")
fi
MODEL=$(tr -d '\0' < /sys/firmware/devicetree/base/model 2>/dev/null || echo Unknown)
REG_PAYLOAD=$(jq -n \
--arg machine "$MACHINE_ID" \
--arg name "$(hostname -f)" \
--arg setup "$SETUP_CODE" \
--arg resolution "1920x1080" \
--arg model "$MODEL" \
'{
machineId: $machine,
name: $name,
setupCode: ($setup | if . == "" then null else . end),
resolution: $resolution,
hardwareModel: $model,
platform: "linux-arm64-pi"
}')
for attempt in 1 2; do
HTTP_STATUS=$(curl -sk -o /tmp/register-response.json -w "%{http_code}" \
--max-time 15 \
-X POST "${SIGNAGE_URL}/api/v1/nodes/register" \
-H "Content-Type: application/json" \
-d "$REG_PAYLOAD" || echo "000")
if [[ "$HTTP_STATUS" == "200" || "$HTTP_STATUS" == "201" ]]; then
break
fi
echo "[$(date -Is)] bootstrap: register attempt $attempt returned $HTTP_STATUS" >&2
sleep 5
done
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then
echo "[$(date -Is)] bootstrap: register failed after 2 attempts" >&2
exit 2
fi
NODE_ID=$(jq -r '.nodeId // empty' /tmp/register-response.json)
if [[ -z "$NODE_ID" ]]; then
echo "[$(date -Is)] bootstrap: register response did not include nodeId" >&2
exit 2
fi
jq --arg id "$NODE_ID" '.nodeId = $id' "$NODE_JSON" > "${NODE_JSON}.tmp" && mv "${NODE_JSON}.tmp" "$NODE_JSON"
if [[ -s "$SETUP_CODE_FILE" ]]; then
curl -sk -X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/approve-via-setup-code" \
-H "Content-Type: application/json" \
-d "{\"setupCode\":\"${SETUP_CODE}\"}" \
-o /dev/null || true
fi
STATUS=""
DEADLINE=$(( $(date +%s) + 1800 ))
while (( $(date +%s) < DEADLINE )); do
STATUS=$(curl -sk --max-time 5 "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/status" | jq -r '.status // empty')
if [[ "$STATUS" == "Approved" || "$STATUS" == "Enrolled" || "$STATUS" == "Online" ]]; then
break
fi
sleep 15
done
if [[ "$STATUS" != "Approved" && "$STATUS" != "Enrolled" && "$STATUS" != "Online" ]]; then
echo "[$(date -Is)] bootstrap: approval not granted within 30min budget" >&2
exit 3
fi
KEY_PATH="${CERT_DIR}/client.key"
CSR_PATH="${CERT_DIR}/client.csr"
openssl ecparam -genkey -name prime256v1 -out "$KEY_PATH"
openssl req -new -key "$KEY_PATH" -out "$CSR_PATH" \
-subj "/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi"
ENROLL_PAYLOAD=$(jq -n --arg csr "$(cat "$CSR_PATH")" '{certificateSigningRequest: $csr}')
HTTP_STATUS=$(curl -sk -o /tmp/enroll-response.json -w "%{http_code}" \
--max-time 15 \
-X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/enroll" \
-H "Content-Type: application/json" \
-d "$ENROLL_PAYLOAD")
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then
echo "[$(date -Is)] bootstrap: enroll failed with HTTP $HTTP_STATUS" >&2
exit 4
fi
jq -r '.clientCertificatePem // .signedCertificatePem' /tmp/enroll-response.json > "${CERT_DIR}/client.crt"
jq -r '.caCertificatePem' /tmp/enroll-response.json > "${CERT_DIR}/ca-chain.pem"
P12_PASS=$(openssl rand -hex 24)
echo -n "$P12_PASS" > "${CERT_DIR}/client.p12.pass"
chmod 0600 "${CERT_DIR}/client.p12.pass"
openssl pkcs12 -export \
-inkey "$KEY_PATH" \
-in "${CERT_DIR}/client.crt" \
-certfile "${CERT_DIR}/ca-chain.pem" \
-out "${CERT_DIR}/client.p12" \
-password "pass:${P12_PASS}"
chown fc-signage:fc-signage "${CERT_DIR}"/* "$NODE_JSON"
chmod 0640 "${CERT_DIR}/client.p12" "${CERT_DIR}/client.crt" "${CERT_DIR}/ca-chain.pem" "$KEY_PATH"
chmod 0600 "${CERT_DIR}/client.p12.pass"
EXPIRY=$(openssl x509 -in "${CERT_DIR}/client.crt" -enddate -noout | sed 's/notAfter=//')
jq --arg ts "$(date -Is)" --arg exp "$EXPIRY" \
'.enrolledAt = $ts | .certExpiry = $exp' "$NODE_JSON" > "${NODE_JSON}.tmp" \
&& mv "${NODE_JSON}.tmp" "$NODE_JSON"
systemctl start flowercore-signage-detect-display.service || true
systemctl start flowercore-signage-player-pi.service || true
echo "[$(date -Is)] bootstrap: enrolled and kiosk started (NodeId=${NODE_ID})"

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
sleep 2
systemctl start flowercore-signage-detect-display.service || true
systemctl restart flowercore-signage-player-pi.service

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
NODE_JSON="/etc/flowercore/signage-node.json"
NODE_ID=$(jq -r '.nodeId' "$NODE_JSON")
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
CERT_DIR="/etc/fc-signage-player"
CERT_THUMB=$(openssl pkcs12 -in "$CERT_DIR/client.p12" -passin file:"$CERT_DIR/client.p12.pass" -nodes -nokeys 2>/dev/null \
| openssl x509 -fingerprint -sha256 -noout \
| sed 's/.*=//' \
| tr -d ':')
PLAYER_URL="${SIGNAGE_URL}/player/${NODE_ID}/embed?token=${CERT_THUMB}"
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 \
--cert-type P12 --cert "$CERT_DIR/client.p12:$(cat "$CERT_DIR/client.p12.pass")" \
"$PLAYER_URL" || echo "000")
mkdir -p /var/log/fc-signage-player
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "301" && "$HTTP_STATUS" != "302" ]]; then
echo "[$(date -Is)] /embed returned $HTTP_STATUS; falling back to /player/${NODE_ID}" \
>> /var/log/fc-signage-player/url-divergence.log
PLAYER_URL="${SIGNAGE_URL}/player/${NODE_ID}?token=${CERT_THUMB}"
fi
exec chromium-browser \
--kiosk \
--noerrdialogs \
--disable-infobars \
--disable-translate \
--disable-features=TranslateUI,InfiniteSessionRestore \
--autoplay-policy=no-user-gesture-required \
--password-store=basic \
--user-data-dir=/var/lib/fc-signage-player/profile \
--disk-cache-dir=/var/lib/fc-signage-player/cache \
--disk-cache-size=104857600 \
--no-first-run \
--no-default-browser-check \
--check-for-update-interval=2592000 \
--enable-features=OverlayScrollbar \
--start-fullscreen \
--window-position=0,0 \
--window-size=1920,1080 \
"$PLAYER_URL"

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p /var/log/fc-signage-player
for f in /etc/flowercore/signage-node.json /etc/fc-signage-player/client.p12 /etc/fc-signage-player/client.p12.pass; do
if [[ ! -r "$f" ]]; then
echo "[$(date -Is)] prelaunch: missing or unreadable $f" >&2
exit 1
fi
done
if openssl pkcs12 -in /etc/fc-signage-player/client.p12 -passin file:/etc/fc-signage-player/client.p12.pass -nokeys -clcerts 2>/dev/null \
| openssl x509 -checkend $((7*24*3600)) -noout; then
:
else
echo "[$(date -Is)] prelaunch: client cert expires within 7 days" >&2
fi
echo "[$(date -Is)] prelaunch: ok" | tee -a /var/log/fc-signage-player/prelaunch.log

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
CERT_DIR="/etc/fc-signage-player"
NODE_JSON="/etc/flowercore/signage-node.json"
SIGNAGE_URL="${FC_SIGNAGE_URL:-https://signage.iamworkin.lan}"
[[ -s "$CERT_DIR/client.crt" ]] || { echo "no cert to renew"; exit 0; }
if openssl x509 -in "$CERT_DIR/client.crt" -checkend $((30*24*3600)) -noout; then
exit 0
fi
NODE_ID=$(jq -r '.nodeId' "$NODE_JSON")
NEW_KEY="$CERT_DIR/client.key.new"
NEW_CSR="$CERT_DIR/client.csr.new"
openssl ecparam -genkey -name prime256v1 -out "$NEW_KEY"
openssl req -new -key "$NEW_KEY" -out "$NEW_CSR" \
-subj "/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi"
HTTP_STATUS=$(curl -sk -o /tmp/renew-response.json -w "%{http_code}" \
--cert "$CERT_DIR/client.crt" --key "$CERT_DIR/client.key" \
-X POST "${SIGNAGE_URL}/api/v1/nodes/${NODE_ID}/renew" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg csr "$(cat "$NEW_CSR")" '{certificateSigningRequest: $csr}')")
if [[ "$HTTP_STATUS" != "200" && "$HTTP_STATUS" != "201" ]]; then
echo "[$(date -Is)] renew: failed HTTP $HTTP_STATUS; leaving old cert in place" >&2
exit 5
fi
jq -r '.clientCertificatePem // .signedCertificatePem' /tmp/renew-response.json > "$CERT_DIR/client.crt.new"
jq -r '.caCertificatePem' /tmp/renew-response.json > "$CERT_DIR/ca-chain.pem.new"
P12_PASS=$(cat "$CERT_DIR/client.p12.pass")
openssl pkcs12 -export -inkey "$NEW_KEY" -in "$CERT_DIR/client.crt.new" \
-certfile "$CERT_DIR/ca-chain.pem.new" \
-out "$CERT_DIR/client.p12.new" -password "pass:${P12_PASS}"
mv "$CERT_DIR/client.key.new" "$CERT_DIR/client.key"
mv "$CERT_DIR/client.crt.new" "$CERT_DIR/client.crt"
mv "$CERT_DIR/ca-chain.pem.new" "$CERT_DIR/ca-chain.pem"
mv "$CERT_DIR/client.p12.new" "$CERT_DIR/client.p12"
chown fc-signage:fc-signage "$CERT_DIR"/client.*
systemctl restart flowercore-signage-player-pi.service

View File

@@ -0,0 +1,3 @@
# Restart kiosk and redeclare capabilities when HDMI connect/disconnect changes DRM state.
SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl restart flowercore-signage-player-pi.service"
SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl start flowercore-signage-detect-display.service"

View File

@@ -0,0 +1,16 @@
[Unit]
Description=FlowerCore Signage Pi: first-boot identity + mTLS enrollment
Wants=network-online.target
After=network-online.target
Before=flowercore-signage-player-pi.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/flowercore-signage-bootstrap.sh
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
TimeoutStartSec=2100
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=FlowerCore Signage Pi: detect connected display + declare capabilities
After=flowercore-signage-bootstrap.service
[Service]
Type=oneshot
User=fc-signage
ExecStart=/usr/local/bin/fc-signage-detect-display

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Daily FlowerCore Signage Pi display capability redeclaration
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
OnBootSec=30s
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=FlowerCore Signage Pi Player HDMI hotplug responder
DefaultDependencies=no
[Service]
Type=oneshot
ExecStart=/usr/local/bin/flowercore-signage-hdmi-respond.sh

View File

@@ -0,0 +1,30 @@
[Unit]
Description=FlowerCore Digital Signage Pi Player (Chromium kiosk)
Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/appletv-pi-signage-agents-design.md
Wants=network-online.target
After=network-online.target graphical.target
ConditionPathExists=/etc/flowercore/signage-node.json
ConditionPathExists=/etc/fc-signage-player/client.p12
[Service]
Type=simple
User=fc-signage
Group=fc-signage
WorkingDirectory=/var/lib/fc-signage-player
EnvironmentFile=-/etc/flowercore/signage-player.env
ExecStartPre=/usr/local/bin/flowercore-signage-prelaunch.sh
ExecStart=/usr/local/bin/flowercore-signage-launch.sh
Restart=always
RestartSec=10s
StartLimitBurst=5
StartLimitIntervalSec=300s
MemoryMax=2G
MemoryHigh=1500M
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/fc-signage-player /var/log/fc-signage-player
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=graphical.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=FlowerCore Signage Pi: cert renewal worker
[Service]
Type=oneshot
ExecStart=/usr/local/bin/flowercore-signage-renew-cert.sh

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Daily check for FlowerCore Signage Pi cert renewal
[Timer]
OnCalendar=daily
RandomizedDelaySec=2h
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,208 @@
# GitHub Actions self-hosted Linux runner — Phase 2 K8s deployment
#
# Phase 1 (current): BLUEJAY-WS registered manually as a Windows runner
# with label "fc-build-windows" via config.cmd (see docs/infrastructure/
# self-hosted-runner-fleet.md §WPF Build Runner).
#
# Phase 2 (this file): ephemeral Linux runner in RKE2 for non-WPF builds
# (Blazor Server, class libraries, operators, integration tests). Reduces
# billing for ubuntu-24.04 jobs that run on GitHub-hosted runners today.
#
# Runner image: myoung34/github-runner:latest
# EPHEMERAL=true — each pod runs exactly one job then exits; the
# Deployment controller immediately recreates it and re-registers.
# Prevents job queue starvation when two jobs overlap.
#
# NuGet cache: 5Gi Longhorn RWO PVC mounted at /home/runner/.nuget/packages
# Persists NuGet packages across ephemeral pod restarts (not shared across
# simultaneous runner pods; single-replica constraint below).
#
# Credentials:
# OnePasswordItem "GitHub Runner Registration Token" → Secret
# github-runner-token with field "credential" used as RUNNER_TOKEN.
# Operator must create/rotate the 1P item manually; registration tokens
# expire after 1h — use a fine-grained PAT with admin:org_hook scope
# or a re-registration script. See docs/infrastructure/
# self-hosted-runner-fleet.md §Security.
# Until that item exists and the Secret contains key "credential", this
# deployment intentionally stays at replicas: 0.
#
# Security model:
# - No ClusterRole / ClusterRoleBinding — runner has no K8s API access.
# - securityContext: runAsNonRoot with read-only root filesystem where
# possible (runner image needs /tmp and /home/runner writable).
# - Fork pull-request approval required on the GitHub repo settings.
# - RUNNER_ALLOW_RUNASROOT=false is the default.
#
# Cost: Phase 2 eliminates GitHub-hosted ubuntu-24.04 billing; break-even
# vs electricity is ~1 000 min/month at current TOU rates.
#
# Node placement: rke2-server (10.0.56.11) only — Longhorn RWO PVC must
# land on the same node as the volume, and the server node has the most
# spare capacity for burst CI workloads.
#
# Designs: docs/infrastructure/self-hosted-runner-fleet.md
# Questions: Q-CI-1..5 (all Recommended defaults)
---
apiVersion: v1
kind: Namespace
metadata:
name: github-runner
labels:
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
---
# 1Password secret sync — creates github-runner-token K8s Secret.
# Fields expected in the 1Password item:
# credential — GitHub runner registration token (or PAT for re-reg script)
# Item path convention: vaults/IAmWorkin/items/<exact item title>
# Current required title: "GitHub Runner Registration Token"
# Operator MUST create this item before replicas can be raised above 0.
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: github-runner-token
namespace: github-runner
annotations:
flowercore.io/operator-action: "Create IAmWorkin item 'GitHub Runner Registration Token' with field 'credential'."
flowercore.io/replica-gate: "Keep Deployment replicas at 0 until github-runner-token Secret exists with key credential."
flowercore.io/provisioning-status: "awaiting-operator-secret-provisioning"
labels:
app.kubernetes.io/component: credentials
app.kubernetes.io/part-of: flowercore
spec:
itemPath: vaults/IAmWorkin/items/GitHub Runner Registration Token
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: github-runner-nuget-cache
namespace: github-runner
labels:
app.kubernetes.io/component: cache
app.kubernetes.io/part-of: flowercore
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
volumeMode: Filesystem
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: github-runner
namespace: github-runner
labels:
app.kubernetes.io/component: runner
app.kubernetes.io/part-of: flowercore
# No ClusterRole or ClusterRoleBinding — runner has zero K8s API privileges.
# CI jobs that need kubectl must supply their own kubeconfig via a secret
# injected at the job level, not via this service account.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: github-runner
namespace: github-runner
annotations:
flowercore.io/replica-gate: "Scale to 1 only after the 1Password item exists and github-runner-token has key credential."
labels:
app.kubernetes.io/name: github-runner
app.kubernetes.io/component: runner
app.kubernetes.io/part-of: flowercore
app.kubernetes.io/managed-by: argocd
flowercore.io/created-by: argocd
spec:
# Single replica enforced: the Longhorn RWO PVC can only be mounted by
# one pod at a time. Each pod re-registers as an ephemeral runner after
# completing a job (EPHEMERAL=true restarts the container, not the pod,
# so the PVC stays attached between jobs).
# Intentionally 0 while the GitHub runner token item is absent. Follow-up
# PR should set replicas: 1 only after operator provisioning and Secret
# sync verification.
replicas: 0
selector:
matchLabels:
app.kubernetes.io/name: github-runner
# Use Recreate to avoid the Multi-Attach RWO error during rollouts.
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: github-runner
app.kubernetes.io/component: runner
app.kubernetes.io/part-of: flowercore
flowercore.io/created-by: argocd
spec:
serviceAccountName: github-runner
# Pin to rke2-server so the Longhorn RWO volume is always on the same node.
nodeSelector:
kubernetes.io/hostname: rke2-server
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
containers:
- name: runner
image: myoung34/github-runner:latest
imagePullPolicy: Always
env:
# GitHub org/repo targeting.
# Set REPO_URL for a repo-scoped runner (cheaper, simpler).
# Switch to ORG_NAME + empty REPO_URL for an org-scoped runner.
- name: REPO_URL
value: "https://github.com/astoltz/FlowerCore.Common"
- name: RUNNER_NAME_PREFIX
value: "rke2-linux"
- name: RUNNER_WORKDIR
value: "/tmp/runner/work"
# EPHEMERAL=true: runner deregisters after one job; container
# exits with code 0; Deployment controller restarts it and a
# fresh registration occurs. Prevents stale runner accumulation.
- name: EPHEMERAL
value: "true"
# Labels used by workflow files: runs-on: [self-hosted, linux, fc-build-linux]
- name: LABELS
value: "self-hosted,linux,fc-build-linux"
# Registration token injected from 1Password via OnePasswordItem CRD.
- name: RUNNER_TOKEN
valueFrom:
secretKeyRef:
name: github-runner-token
key: credential
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2000m"
memory: "4Gi"
volumeMounts:
- name: nuget-cache
mountPath: /home/runner/.nuget/packages
- name: tmp
mountPath: /tmp
# Liveness: runner process is alive.
livenessProbe:
exec:
command:
- /bin/sh
- -c
- "pgrep -f Runner.Listener > /dev/null"
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 3
volumes:
- name: nuget-cache
persistentVolumeClaim:
claimName: github-runner-nuget-cache
- name: tmp
emptyDir: {}
# Restart policy: Always — the Deployment controller handles
# re-registration after each ephemeral job completes.
restartPolicy: Always

View File

@@ -305,15 +305,17 @@ spec:
path: /
port: 8080
initialDelaySeconds: 60
timeoutSeconds: 5
timeoutSeconds: 15
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 5
timeoutSeconds: 15
failureThreshold: 3
---
apiVersion: v1
kind: Service

View File

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

View File

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

View File

@@ -0,0 +1,266 @@
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace BluejayInfraLint.Tests;
[Trait("Category", "Unit")]
public sealed class PiSignagePlayerArtifactTests
{
private static readonly string Root = FindRepoRoot();
private static readonly string AppRoot = Path.Combine(Root, "apps", "fc-signage-pi-player");
public static TheoryData<string> RequiredArtifacts => new()
{
"README.md",
"systemd/flowercore-signage-player-pi.service",
"systemd/flowercore-signage-player-pi-hdmi.service",
"systemd/flowercore-signage-bootstrap.service",
"systemd/flowercore-signage-renew.service",
"systemd/flowercore-signage-renew.timer",
"systemd/flowercore-signage-detect-display.service",
"systemd/flowercore-signage-detect-display.timer",
"systemd/99-flowercore-signage-hdmi.rules",
"chromium-policies/flowercore-signage.json",
"scripts/flowercore-signage-launch.sh",
"scripts/flowercore-signage-prelaunch.sh",
"scripts/flowercore-signage-bootstrap.sh",
"scripts/flowercore-signage-renew-cert.sh",
"scripts/flowercore-signage-hdmi-respond.sh",
"scripts/fc-signage-detect-display",
};
[Theory]
[MemberData(nameof(RequiredArtifacts))]
public void RequiredArtifacts_ArePresent(string relativePath)
{
File.Exists(Path.Combine(AppRoot, relativePath)).Should().BeTrue(relativePath);
}
[Fact]
public void PlayerService_UsesExpectedRestartAndMemoryGuards()
{
var unit = Read("systemd/flowercore-signage-player-pi.service");
unit.Should().Contain("Restart=always");
unit.Should().Contain("RestartSec=10s");
unit.Should().Contain("StartLimitBurst=5");
unit.Should().Contain("StartLimitIntervalSec=300s");
unit.Should().Contain("MemoryMax=2G");
}
[Fact]
public void PlayerService_IsGatedByNodeIdentityAndMtlsCertificate()
{
var unit = Read("systemd/flowercore-signage-player-pi.service");
unit.Should().Contain("ConditionPathExists=/etc/flowercore/signage-node.json");
unit.Should().Contain("ConditionPathExists=/etc/fc-signage-player/client.p12");
unit.Should().Contain("ExecStartPre=/usr/local/bin/flowercore-signage-prelaunch.sh");
}
[Fact]
public void LaunchScript_TriesEmbedThenFallsBackToBarePlayerRoute()
{
var script = Read("scripts/flowercore-signage-launch.sh");
script.Should().Contain("/player/${NODE_ID}/embed?token=${CERT_THUMB}");
script.Should().Contain("url-divergence.log");
script.Should().Contain("/player/${NODE_ID}?token=${CERT_THUMB}");
}
[Fact]
public void LaunchScript_DisablesChromiumPromptsAndRuntimeUpdates()
{
var script = Read("scripts/flowercore-signage-launch.sh");
script.Should().Contain("--noerrdialogs");
script.Should().Contain("--disable-infobars");
script.Should().Contain("--password-store=basic");
script.Should().Contain("--check-for-update-interval=2592000");
}
[Fact]
public void PrelaunchScript_AbortsWhenRequiredFilesAreMissing()
{
var script = Read("scripts/flowercore-signage-prelaunch.sh");
script.Should().Contain("for f in /etc/flowercore/signage-node.json /etc/fc-signage-player/client.p12 /etc/fc-signage-player/client.p12.pass");
script.Should().Contain("exit 1");
script.Should().Contain("-checkend $((7*24*3600))");
}
[Fact]
public void BootstrapScript_IsIdempotentWhenAlreadyEnrolled()
{
var script = Read("scripts/flowercore-signage-bootstrap.sh");
script.Should().Contain("already enrolled");
script.Should().Contain("exit 0");
script.Should().Contain(".enrolledAt");
}
[Fact]
public void BootstrapScript_GeneratesStableMachineIdFromUuid()
{
var script = Read("scripts/flowercore-signage-bootstrap.sh");
script.Should().Contain("uuidgen");
script.Should().Contain("cut -c1-16");
script.Should().Contain("machineId");
}
[Fact]
public void BootstrapScript_RetriesRegisterOnceForFirstCallRace()
{
var script = Read("scripts/flowercore-signage-bootstrap.sh");
script.Should().Contain("for attempt in 1 2");
script.Should().Contain("register attempt $attempt returned");
script.Should().Contain("sleep 5");
}
[Fact]
public void BootstrapScript_SupportsSetupCodeAndApprovalPollingBudget()
{
var script = Read("scripts/flowercore-signage-bootstrap.sh");
script.Should().Contain("signage-setup-code");
script.Should().Contain("approve-via-setup-code");
script.Should().Contain("+ 1800");
script.Should().Contain("sleep 15");
}
[Fact]
public void BootstrapScript_CsrSubjectIdentifiesPiPlayer()
{
var script = Read("scripts/flowercore-signage-bootstrap.sh");
script.Should().Contain("/CN=${NODE_ID}/O=FlowerCore/OU=SignagePlayer-Pi");
}
[Fact]
public void BootstrapScript_PersistsCertificateAsP12WithRestrictivePermissions()
{
var script = Read("scripts/flowercore-signage-bootstrap.sh");
script.Should().Contain("openssl pkcs12 -export");
script.Should().Contain("client.p12.pass");
script.Should().Contain("chmod 0600");
script.Should().Contain("chmod 0640");
}
[Fact]
public void RenewScript_OnlyRunsWhenCertHasLessThanThirtyDays()
{
var script = Read("scripts/flowercore-signage-renew-cert.sh");
script.Should().Contain("-checkend $((30*24*3600))");
script.Should().Contain("exit 0");
script.Should().Contain("/renew");
}
[Fact]
public void RenewScript_AtomicallySwapsNewCertificateFiles()
{
var script = Read("scripts/flowercore-signage-renew-cert.sh");
script.Should().Contain("client.key.new");
script.Should().Contain("mv \"$CERT_DIR/client.key.new\" \"$CERT_DIR/client.key\"");
script.Should().Contain("mv \"$CERT_DIR/client.p12.new\" \"$CERT_DIR/client.p12\"");
}
[Fact]
public void HdmiRule_RestartsPlayerAndRunsCapabilityDetection()
{
var rule = Read("systemd/99-flowercore-signage-hdmi.rules");
rule.Should().Contain("KERNEL==\"card?-HDMI-A-?\"");
rule.Should().Contain("restart flowercore-signage-player-pi.service");
rule.Should().Contain("start flowercore-signage-detect-display.service");
}
[Fact]
public void DetectDisplayServiceAndTimer_RunAtBootAndDaily()
{
var service = Read("systemd/flowercore-signage-detect-display.service");
var timer = Read("systemd/flowercore-signage-detect-display.timer");
service.Should().Contain("ExecStart=/usr/local/bin/fc-signage-detect-display");
timer.Should().Contain("OnBootSec=30s");
timer.Should().Contain("OnCalendar=daily");
timer.Should().Contain("RandomizedDelaySec=1h");
}
[Fact]
public void DetectDisplayScript_EmitsDisconnectedProfileWhenNoHdmiIsPresent()
{
var script = Read("scripts/fc-signage-detect-display");
script.Should().Contain("displayConnected: false");
script.Should().Contain("No HDMI display detected");
}
[Fact]
public void DetectDisplayScript_ParsesEdidForHdrResolutionAndAudio()
{
var script = Read("scripts/fc-signage-detect-display");
script.Should().Contain("edid-decode");
script.Should().Contain("HDR (Static|Dynamic) Metadata Block");
script.Should().Contain("maxResolution");
script.Should().Contain("hasAudioOutput");
}
[Fact]
public void DetectDisplayScript_TriesBothForwardCompatibleCapabilityEndpoints()
{
var script = Read("scripts/fc-signage-detect-display");
script.Should().Contain("/api/v1/nodes/${NODE_ID}/capabilities");
script.Should().Contain("/api/v1/displays/${NODE_ID}/capability-profile");
script.Should().Contain("no endpoint accepted the profile");
}
[Fact]
public void ChromiumPolicy_IsValidJsonAndDisablesCredentialPrompts()
{
using var doc = JsonDocument.Parse(Read("chromium-policies/flowercore-signage.json"));
var root = doc.RootElement;
root.GetProperty("AutofillAddressEnabled").GetBoolean().Should().BeFalse();
root.GetProperty("AutofillCreditCardEnabled").GetBoolean().Should().BeFalse();
root.GetProperty("PasswordManagerEnabled").GetBoolean().Should().BeFalse();
root.GetProperty("ExtensionInstallBlocklist")[0].GetString().Should().Be("*");
}
[Fact]
public void RenewalTimer_UsesDailyCadenceWithTwoHourJitter()
{
var timer = Read("systemd/flowercore-signage-renew.timer");
timer.Should().Contain("OnCalendar=daily");
timer.Should().Contain("RandomizedDelaySec=2h");
timer.Should().Contain("Persistent=true");
}
private static string Read(string relativePath)
=> File.ReadAllText(Path.Combine(AppRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, "apps"))
&& File.Exists(Path.Combine(current.FullName, "README.md")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not find bluejay-infra root.");
}
}